diff --git a/.gitattributes b/.gitattributes index 79084045d..f79829ac4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,4 @@ Makefile export-ignore patches.lock.json export-ignore /phpbench.json export-ignore +/bench/ export-ignore diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 000000000..d5335edd0 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,109 @@ +name: Benchmark + +on: + workflow_dispatch: + inputs: + runs: + description: Repetitions of the whole matrix + required: false + default: '1' + pull_request: + paths: + - 'src/**' + - 'core/**' + - 'bench/**' + - 'composer.json' + - '.github/workflows/benchmark.yml' + +permissions: + contents: read + +concurrency: + cancel-in-progress: true + group: benchmark-${{ github.ref }} + +jobs: + benchmark: + permissions: + contents: read + name: RoadRunner vs sdk-core + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + XDEBUG_MODE: off + TEMPORAL_ADDRESS: 127.0.0.1:7557 + steps: + - name: Check Out Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: 8.4 + extensions: dom, sockets, curl, protobuf, ffi, pcntl, posix + ini-values: opcache.enable_cli=1 + + - name: Install dependencies with composer + uses: ramsey/composer-install@5c2bcf28d7b060ef3c601d7b476d5430a7b46c27 # v4 + + - name: Download binaries + run: composer get:binaries + + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libprotobuf-dev + + - name: Cache the bridge build + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + core/bridge/target + key: bridge-${{ runner.os }}-${{ hashFiles('core/bridge/Cargo.lock', 'core/bridge/src/**', 'core/bridge/build.rs') }} + restore-keys: bridge-${{ runner.os }}- + + - name: Build the sdk-core bridge + run: cargo build --release --lib --manifest-path core/bridge/Cargo.toml + + - name: Start the Temporal server + run: | + docker compose -f bench/server/compose.yaml up -d + for i in $(seq 1 60); do + ./temporal operator namespace describe default --address "$TEMPORAL_ADDRESS" >/dev/null 2>&1 && exit 0 + sleep 2 + done + docker compose -f bench/server/compose.yaml logs + exit 1 + + - name: Run the benchmark matrix + timeout-minutes: 45 + run: bench/matrix.sh + env: + BENCH_RUNS: ${{ inputs.runs || '1' }} + BENCH_TIMEOUT: 180 + BENCH_RUN_TIMEOUT: 420 + BENCH_RESULTS: ${{ github.workspace }}/bench/matrix.jsonl + + - name: Print results + if: always() + run: | + { + echo "## Worker benchmark: RoadRunner vs sdk-core" + echo + echo "Runner: $(nproc) CPU, $(free -g | awk '/Mem/ {print $2}') GB, PHP $(php -r 'echo PHP_VERSION;'). The Temporal server runs on the same runner, compare the rows with each other only." + echo + bench/summary.sh bench/matrix.jsonl + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload raw results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: benchmark-results + path: bench/matrix.jsonl + if-no-files-found: ignore + + - name: Stop the Temporal server + if: always() + run: docker compose -f bench/server/compose.yaml down diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml new file mode 100644 index 000000000..d612c821e --- /dev/null +++ b/.github/workflows/core.yml @@ -0,0 +1,134 @@ +name: Core transport + +on: + push: + paths: + - 'src/**' + - 'testing/**' + - 'tests/**' + - 'core/**' + - 'composer.json' + - 'phpunit.xml.dist' + - '.github/workflows/core.yml' + pull_request: + paths: + - 'src/**' + - 'testing/**' + - 'tests/**' + - 'core/**' + - 'composer.json' + - 'phpunit.xml.dist' + - '.github/workflows/core.yml' + +permissions: + contents: read + +concurrency: + cancel-in-progress: true + group: core-${{ github.ref }} + +env: + CARGO_TERM_COLOR: never + BRIDGE_MANIFEST: core/bridge/Cargo.toml + +jobs: + bridge: + name: Bridge (Rust) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check Out Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libprotobuf-dev + + - name: Cache the bridge build + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + core/bridge/target + key: bridge-checks-${{ runner.os }}-${{ hashFiles('core/bridge/Cargo.lock', 'core/bridge/src/**', 'core/bridge/build.rs') }} + restore-keys: bridge-checks-${{ runner.os }}- + + - name: Format + run: cargo fmt --manifest-path "$BRIDGE_MANIFEST" -- --check + + - name: Clippy + run: cargo clippy --release --all-targets --manifest-path "$BRIDGE_MANIFEST" -- -D warnings + + - name: Unit tests + run: cargo test --release --lib --manifest-path "$BRIDGE_MANIFEST" + + - name: The generated header matches the sources + run: | + cargo build --release --lib --manifest-path "$BRIDGE_MANIFEST" + git diff --exit-code core/bridge/include + + php: + name: PHP ${{ matrix.php }} on sdk-core, ${{ matrix.suite }} + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - { suite: core, php: '8.2' } + - { suite: core, php: '8.4' } + - { suite: functional, php: '8.4' } + - { suite: functional-timeskip, php: '8.4' } + - { suite: acceptance, php: '8.4' } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TEMPORAL_HEADLESS: true + TEMPORAL_WORKER_TRANSPORT: core + XDEBUG_MODE: off + steps: + - name: Check Out Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + + - name: Setup PHP without ext-grpc + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, sockets, curl, protobuf, ffi, pcntl, posix, :grpc + + - name: Install dependencies with composer + uses: ramsey/composer-install@5c2bcf28d7b060ef3c601d7b476d5430a7b46c27 # v4 + + - name: Download binaries + run: composer get:binaries + + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libprotobuf-dev + + - name: Cache the bridge build + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + core/bridge/target + key: bridge-${{ runner.os }}-${{ hashFiles('core/bridge/Cargo.lock', 'core/bridge/src/**', 'core/bridge/build.rs') }} + restore-keys: bridge-${{ runner.os }}- + + - name: Build the sdk-core bridge + run: cargo build --release --lib --manifest-path "$BRIDGE_MANIFEST" + + - name: Unit and native tests + if: matrix.suite == 'core' + run: tests/runner.php vendor/bin/phpunit --testsuite=Core --color=always --testdox + + - name: Functional + if: matrix.suite == 'functional' + run: composer test:func + + - name: Functional with time skipping + if: matrix.suite == 'functional-timeskip' + run: composer test:func-timeskip + + - name: Acceptance + if: matrix.suite == 'acceptance' + run: vendor/bin/phpunit --testsuite=Acceptance --testdox diff --git a/.github/workflows/run-test-suite.yml b/.github/workflows/run-test-suite.yml index 8d74a977c..16196cd4d 100644 --- a/.github/workflows/run-test-suite.yml +++ b/.github/workflows/run-test-suite.yml @@ -62,7 +62,7 @@ jobs: uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: ${{ matrix.php }} - extensions: dom, sockets, grpc, curl ${{ matrix.extensions-suffix }} + extensions: dom, sockets, grpc, curl, ffi ${{ matrix.extensions-suffix }} - name: Validate composer.json and composer.lock run: composer validate --strict diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 825e527a0..604c345f2 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -7,6 +7,7 @@ return \Spiral\CodeStyle\Builder::create() ->include(__DIR__ . '/src') ->include(__DIR__ . '/testing/src') + ->include(__DIR__ . '/core/grpc') ->include(__FILE__) ->allowRisky(true) ->build(); diff --git a/bench/.gitignore b/bench/.gitignore new file mode 100644 index 000000000..69a3ae9d7 --- /dev/null +++ b/bench/.gitignore @@ -0,0 +1,3 @@ +results.jsonl +matrix.jsonl +rusage diff --git a/bench/.rr.core.yaml b/bench/.rr.core.yaml new file mode 100644 index 000000000..6ee110c57 --- /dev/null +++ b/bench/.rr.core.yaml @@ -0,0 +1,28 @@ +version: "3" + +rpc: + listen: tcp://127.0.0.1:6556 + +kv: + bench: + driver: memory + config: {} + +service: + workflow: + command: "php -dopcache.enable_cli=1 -dxdebug.mode=off -dpcov.enabled=0 worker.php" + process_num: ${BENCH_WORKFLOW_WORKERS} + remain_after_exit: true + restart_sec: 1 + env: + TEMPORAL_CORE_ROLE: workflow + activity: + command: "php -dopcache.enable_cli=1 -dxdebug.mode=off -dpcov.enabled=0 worker.php" + process_num: ${BENCH_ACTIVITY_WORKERS} + remain_after_exit: true + restart_sec: 1 + env: + TEMPORAL_CORE_ROLE: activity + +logs: + level: error diff --git a/bench/.rr.kv.yaml b/bench/.rr.kv.yaml new file mode 100644 index 000000000..2d52a23ad --- /dev/null +++ b/bench/.rr.kv.yaml @@ -0,0 +1,12 @@ +version: "3" + +rpc: + listen: tcp://127.0.0.1:6556 + +kv: + bench: + driver: memory + config: {} + +logs: + level: error diff --git a/bench/.rr.yaml b/bench/.rr.yaml new file mode 100644 index 000000000..e49708df1 --- /dev/null +++ b/bench/.rr.yaml @@ -0,0 +1,21 @@ +version: "3" + +rpc: + listen: tcp://127.0.0.1:6556 + +server: + command: "php -dopcache.enable_cli=1 -dxdebug.mode=off -dpcov.enabled=0 worker.php" + +temporal: + address: ${TEMPORAL_ADDRESS} + namespace: default + activities: + num_workers: ${BENCH_ACTIVITY_WORKERS} + +kv: + bench: + driver: memory + config: {} + +logs: + level: error diff --git a/bench/autoload.php b/bench/autoload.php new file mode 100644 index 000000000..a0eaabd89 --- /dev/null +++ b/bench/autoload.php @@ -0,0 +1,19 @@ +&1 || code=$? + echo "::endgroup::" + if [ "$code" -ne 0 ]; then + echo "FAILED ($code): $*" + FAILED=1 + fi +} +for r in $(seq 1 "${BENCH_RUNS:-2}"); do + for t in rr rr-core; do + run ./run.sh $t seq 1000 1 100 + run ./run.sh $t seq 200 10 100 + run ./run.sh $t noact 2000 0 100 + run ./run.sh $t par 100 20 100 + BENCH_RATE=50 run ./run.sh $t seq 1000 1 100 + BENCH_RATE=100 run ./run.sh $t seq 1000 1 100 + BENCH_RATE=100 run ./run.sh $t noact 1000 0 100 + run ./run.sh $t io 100 10 100 + run ./run.sh $t cpu 300 1 5 + done + BENCH_LABEL=core-4wf BENCH_WORKFLOW_WORKERS=4 run ./run.sh rr-core cpu 300 1 5 + BENCH_LABEL=core-4wf BENCH_WORKFLOW_WORKERS=4 run ./run.sh rr-core noact 2000 0 100 +done +echo MATRIX DONE +exit $FAILED diff --git a/bench/run.sh b/bench/run.sh new file mode 100755 index 000000000..7a236609b --- /dev/null +++ b/bench/run.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ $# -lt 5 ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +TRANSPORT=$1 SCENARIO=$2 WORKFLOWS=$3 ACTIVITIES=$4 PARAM=$5 +BENCH_DIR=$(cd "$(dirname "$0")" && pwd) +cd "$BENCH_DIR" + +RR_BIN=${RR_BIN:-$BENCH_DIR/../rr} +CONCURRENCY=${BENCH_CONCURRENCY:-8} +WARMUP=${BENCH_WARMUP:-20} +RATE=${BENCH_RATE:-0} +TIMEOUT=${BENCH_TIMEOUT:-600} +RESULTS_FILE=${BENCH_RESULTS:-results.jsonl} +PHP="php -dxdebug.mode=off -dpcov.enabled=0" + +export XDEBUG_MODE=off +export TEMPORAL_ADDRESS=${TEMPORAL_ADDRESS:-127.0.0.1:7557} +export BENCH_TRANSPORT=$TRANSPORT +export BENCH_ACTIVITY_WORKERS=${BENCH_ACTIVITY_WORKERS:-4} +export BENCH_WORKFLOW_WORKERS=${BENCH_WORKFLOW_WORKERS:-1} +export BENCH_TASK_QUEUE="bench-$(date +%s)-$$" +export RR_RPC=tcp://127.0.0.1:6556 + +TMP=$(mktemp -d) +WORKER_PID= +KV_PID= +SAMPLER_PID= + +cleanup() { + local code=$? + if [ "$code" -ne 0 ] && [ -f "$TMP/worker.log" ]; then + echo "--- worker log (last 40 lines)" >&2 + tail -40 "$TMP/worker.log" >&2 + fi + [ -n "$SAMPLER_PID" ] && kill "$SAMPLER_PID" 2>/dev/null || true + for pid in $WORKER_PID $KV_PID; do + kill -TERM "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + rm -rf "$TMP" +} +trap cleanup EXIT +trap 'exit 143' TERM INT + +case "$TRANSPORT" in + rr) "$RR_BIN" serve -c .rr.yaml > "$TMP/worker.log" 2>&1 & ;; + rr-core) "$RR_BIN" serve -c .rr.core.yaml > "$TMP/worker.log" 2>&1 & ;; + core) + if [ "$SCENARIO" = kv ]; then + "$RR_BIN" serve -c .rr.kv.yaml > "$TMP/kv.log" 2>&1 & + KV_PID=$! + fi + $PHP -dopcache.enable_cli=1 ${BENCH_WORKER_PHP_FLAGS:-} worker.php > "$TMP/worker.log" 2>&1 & ;; + *) echo "unknown transport: $TRANSPORT" >&2; exit 1 ;; +esac +WORKER_PID=$! + +descendants() { + local child + for child in $(pgrep -P "$1" || true); do + echo "$child" + descendants "$child" + done +} + +worker_pids() { + echo "$WORKER_PID" $(descendants "$WORKER_PID") $KV_PID | tr ' ' ',' +} + +cpu_seconds() { + if [ -d /proc ]; then + for pid in $(worker_pids | tr ',' ' '); do cat "/proc/$pid/stat" 2>/dev/null; done \ + | awk -v hz="$(getconf CLK_TCK)" '{sub(/.*\) /, ""); t+=$12+$13} END {printf "%.2f", t/hz}' + return + fi + ps -o time= -p "$(worker_pids)" | awk '{n=split($1,a,":"); s=0; for(i=1;i<=n;i++) s=s*60+a[i]; t+=s} END {printf "%.2f", t}' +} + +echo "worker started (pid $WORKER_PID), warmup: $WARMUP workflows" >&2 + +if [ "$WARMUP" -gt 0 ]; then + $PHP starter.php --scenario="$SCENARIO" --workflows="$WARMUP" --activities="$ACTIVITIES" --param="$PARAM" --concurrency=2 --timeout="$TIMEOUT" > /dev/null +fi +echo "warmup done" >&2 + +( + while kill -0 "$WORKER_PID" 2>/dev/null; do + ps -o %cpu=,rss= -p "$(worker_pids)" | awk '{c+=$1; r+=$2} END {print c, r}' + sleep 0.5 + done +) > "$TMP/samples" & +SAMPLER_PID=$! + +CPU_BEFORE=$(cpu_seconds) +RUSAGE_BEFORE=$([ -n "${BENCH_RUSAGE:-}" ] && $BENCH_RUSAGE $(worker_pids | tr ',' ' ') || echo "0 0") +RESULT=$($PHP starter.php --scenario="$SCENARIO" --workflows="$WORKFLOWS" --activities="$ACTIVITIES" --param="$PARAM" --concurrency="$CONCURRENCY" --rate="$RATE" --timeout="$TIMEOUT") +CPU_AFTER=$(cpu_seconds) +RUSAGE_AFTER=$([ -n "${BENCH_RUSAGE:-}" ] && $BENCH_RUSAGE $(worker_pids | tr ',' ' ') || echo "0 0") +RUSAGE=$(jq -nc --argjson a "[${RUSAGE_BEFORE// /,}]" --argjson b "[${RUSAGE_AFTER// /,}]" '{instructions: ($b[0] - $a[0]), cycles: ($b[1] - $a[1])}') +WORKER_PROCESSES=$(descendants "$WORKER_PID" | wc -l | tr -d ' ') + +kill "$SAMPLER_PID" 2>/dev/null || true +wait "$SAMPLER_PID" 2>/dev/null || true +SAMPLER_PID= + +STATS=$(awk '{c+=$1; n++; if ($2>r) r=$2} END {if (n == 0) {print "the worker exited before the first sample" > "/dev/stderr"; exit 1} printf "{\"cpu_pct_avg\":%.1f,\"rss_mb_max\":%.1f,\"samples\":%d}", c/n, r/1024, n}' "$TMP/samples") + +echo "$RESULT" | jq -c \ + --arg transport "$TRANSPORT" \ + --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --argjson stats "$STATS" \ + --argjson rusage "$RUSAGE" \ + --argjson cpu "$(awk -v a="$CPU_AFTER" -v b="$CPU_BEFORE" 'BEGIN {printf "%.2f", a - b}')" \ + --argjson workers "$BENCH_ACTIVITY_WORKERS" \ + --argjson procs "$WORKER_PROCESSES" \ + --arg label "${BENCH_LABEL:-$TRANSPORT}" \ + --argjson wfprocs "$BENCH_WORKFLOW_WORKERS" \ + '{time: $time, transport: $transport, label: $label, activity_workers: $workers, workflow_processes: $wfprocs, worker_child_processes: $procs} + . + {worker: ($stats + {cpu_seconds: $cpu} + $rusage)}' \ + | tee -a "$RESULTS_FILE" diff --git a/bench/rusage.c b/bench/rusage.c new file mode 100644 index 000000000..2d09eaf0b --- /dev/null +++ b/bench/rusage.c @@ -0,0 +1,15 @@ +#include +#include +#include +#include +int main(int argc, char **argv) { + unsigned long long ins = 0, cyc = 0; + for (int i = 1; i < argc; i++) { + struct rusage_info_v4 ri; + if (proc_pid_rusage(atoi(argv[i]), RUSAGE_INFO_V4, (rusage_info_t *)&ri) != 0) continue; + ins += ri.ri_instructions; + cyc += ri.ri_cycles; + } + printf("%llu %llu\n", ins, cyc); + return 0; +} diff --git a/bench/server/compose.yaml b/bench/server/compose.yaml new file mode 100644 index 000000000..e663fc0c5 --- /dev/null +++ b/bench/server/compose.yaml @@ -0,0 +1,37 @@ +name: temporal-bench + +services: + postgresql: + image: postgres:16 + environment: + POSTGRES_USER: temporal + POSTGRES_PASSWORD: temporal + command: > + postgres + -c max_connections=500 + -c fsync=off + -c synchronous_commit=off + -c full_page_writes=off + -c shared_buffers=512MB + tmpfs: + - /var/lib/postgresql/data + + temporal: + image: temporalio/auto-setup:1.29.3 + depends_on: + - postgresql + environment: + DB: postgres12 + DB_PORT: 5432 + POSTGRES_USER: temporal + POSTGRES_PWD: temporal + POSTGRES_SEEDS: postgresql + NUM_HISTORY_SHARDS: 512 + SQL_MAX_CONNS: 100 + SQL_MAX_IDLE_CONNS: 100 + DYNAMIC_CONFIG_FILE_PATH: /etc/temporal/config/dynamicconfig/bench.yaml + LOG_LEVEL: error + volumes: + - ./dynamicconfig.yaml:/etc/temporal/config/dynamicconfig/bench.yaml:ro + ports: + - "127.0.0.1:7557:7233" diff --git a/bench/server/dynamicconfig.yaml b/bench/server/dynamicconfig.yaml new file mode 100644 index 000000000..258ae70f5 --- /dev/null +++ b/bench/server/dynamicconfig.yaml @@ -0,0 +1,20 @@ +frontend.rps: + - value: 100000 +frontend.namespaceRPS: + - value: 100000 +frontend.namespaceCount: + - value: 100000 +history.rps: + - value: 100000 +matching.rps: + - value: 100000 +frontend.persistenceMaxQPS: + - value: 100000 +history.persistenceMaxQPS: + - value: 100000 +matching.persistenceMaxQPS: + - value: 100000 +matching.numTaskqueueReadPartitions: + - value: 1 +matching.numTaskqueueWritePartitions: + - value: 1 diff --git a/bench/src/BenchActivity.php b/bench/src/BenchActivity.php new file mode 100644 index 000000000..fd033d302 --- /dev/null +++ b/bench/src/BenchActivity.php @@ -0,0 +1,45 @@ +select(self::KV_STORAGE); + for ($i = 0; $i < $operations; ++$i) { + $storage->set("key-$i", $i); + $storage->get("key-$i"); + } + + return $operations; + } +} diff --git a/bench/src/BenchCpuWorkflow.php b/bench/src/BenchCpuWorkflow.php new file mode 100644 index 000000000..fac23a715 --- /dev/null +++ b/bench/src/BenchCpuWorkflow.php @@ -0,0 +1,38 @@ +withStartToCloseTimeout(30), + ); + + self::burn($burnMilliseconds); + for ($i = 0; $i < $activities; $i++) { + yield $activity->echo('x'); + self::burn($burnMilliseconds); + } + + return $activities; + } + + private static function burn(int $milliseconds): void + { + $until = \hrtime(true) + $milliseconds * 1_000_000; + while (\hrtime(true) < $until) { + } + } +} diff --git a/bench/src/BenchIoWorkflow.php b/bench/src/BenchIoWorkflow.php new file mode 100644 index 000000000..7f6d1320f --- /dev/null +++ b/bench/src/BenchIoWorkflow.php @@ -0,0 +1,31 @@ +withStartToCloseTimeout(30), + ); + $promises = []; + for ($i = 0; $i < $activities; $i++) { + $promises[] = $activity->io($milliseconds); + } + yield Promise::all($promises); + + return $activities; + } +} diff --git a/bench/src/BenchKvWorkflow.php b/bench/src/BenchKvWorkflow.php new file mode 100644 index 000000000..b4a18a0de --- /dev/null +++ b/bench/src/BenchKvWorkflow.php @@ -0,0 +1,29 @@ +withStartToCloseTimeout(30), + ); + + for ($i = 0; $i < $activities; $i++) { + yield $activity->kv($operations); + } + + return $activities; + } +} diff --git a/bench/src/BenchParallelWorkflow.php b/bench/src/BenchParallelWorkflow.php new file mode 100644 index 000000000..938bc3edf --- /dev/null +++ b/bench/src/BenchParallelWorkflow.php @@ -0,0 +1,33 @@ +withStartToCloseTimeout(30), + ); + $payload = \str_repeat('x', $payloadBytes); + + $promises = []; + for ($i = 0; $i < $activities; $i++) { + $promises[] = $activity->echo($payload); + } + yield Promise::all($promises); + + return $activities; + } +} diff --git a/bench/src/BenchWorkflow.php b/bench/src/BenchWorkflow.php new file mode 100644 index 000000000..6ce712f52 --- /dev/null +++ b/bench/src/BenchWorkflow.php @@ -0,0 +1,30 @@ +withStartToCloseTimeout(30), + ); + $payload = \str_repeat('x', $payloadBytes); + + for ($i = 0; $i < $activities; $i++) { + yield $activity->echo($payload); + } + + return $activities; + } +} diff --git a/bench/starter.php b/bench/starter.php new file mode 100644 index 000000000..62f13b2eb --- /dev/null +++ b/bench/starter.php @@ -0,0 +1,173 @@ + 'BenchWorkflow', 'par' => 'BenchParallelWorkflow', 'noact' => 'BenchWorkflow', 'io' => 'BenchIoWorkflow', 'cpu' => 'BenchCpuWorkflow', 'kv' => 'BenchKvWorkflow']; + +$opts = \getopt('', ['scenario:', 'workflows:', 'activities:', 'param:', 'concurrency:', 'timeout:', 'rate:', 'prefix:', 'part:', 'started-at:']); +$scenario = $opts['scenario'] ?? 'seq'; +$workflows = (int) ($opts['workflows'] ?? 100); +if ($workflows < 1) { + throw new \InvalidArgumentException('--workflows must be at least 1'); +} +$activities = $scenario === 'noact' ? 0 : (int) ($opts['activities'] ?? 1); +$param = (int) ($opts['param'] ?? 100); +$concurrency = \max(1, (int) ($opts['concurrency'] ?? 8)); +$timeout = (float) ($opts['timeout'] ?? 600); +$rate = (float) ($opts['rate'] ?? 0); +$address = benchEnv('TEMPORAL_ADDRESS'); +$taskQueue = benchEnv('BENCH_TASK_QUEUE'); +if (!isset(SCENARIO_WORKFLOW[$scenario])) { + throw new \InvalidArgumentException("Unknown scenario: {$scenario}"); +} +$workflowType = SCENARIO_WORKFLOW[$scenario]; +$prefix = $opts['prefix'] ?? \sprintf('bench-%s-%s-', $scenario, \bin2hex(\random_bytes(4))); + +function startWorkflows(string $address, string $taskQueue, string $workflowType, string $prefix, array $indexes, int $activities, int $param, float $rate, float $startedAt): void +{ + $client = WorkflowClient::create(ServiceClient::create($address)); + foreach ($indexes as $i) { + if ($rate > 0) { + $delay = $startedAt + $i / $rate - \microtime(true); + if ($delay > 0) { + \usleep((int) ($delay * 1e6)); + } + } + $stub = $client->newUntypedWorkflowStub( + $workflowType, + WorkflowOptions::new()->withTaskQueue($taskQueue)->withWorkflowId($prefix . $i), + ); + $client->start($stub, $activities, $param); + } +} + +function seconds(Timestamp $time): float +{ + return $time->getSeconds() + $time->getNanos() / 1e9; +} + +function percentile(array $sorted, float $p): float +{ + return $sorted[\max(0, (int) \ceil($p / 100 * \count($sorted)) - 1)]; +} + +if (isset($opts['part'])) { + $part = (int) $opts['part']; + $ids = []; + for ($id = $part; $id < $workflows; $id += $concurrency) { + $ids[] = $id; + } + startWorkflows($address, $taskQueue, $workflowType, $prefix, $ids, $activities, $param, $rate, (float) $opts['started-at']); + exit(0); +} + +$startedAt = \microtime(true); +$starters = []; +for ($part = 0; $part < \min($concurrency, $workflows); $part++) { + $starters[] = \proc_open( + [ + \PHP_BINARY, + ...$_SERVER['argv'], + '--prefix=' . $prefix, + '--part=' . $part, + '--started-at=' . $startedAt, + ], + [\STDIN, \STDOUT, \STDERR], + $pipes, + ); +} +foreach ($starters as $starter) { + while (($status = \proc_get_status($starter))['running']) { + if (\microtime(true) - $startedAt > $timeout) { + foreach ($starters as $running) { + \proc_terminate($running, \SIGKILL); + } + throw new \RuntimeException('A starter process timed out'); + } + \usleep(20_000); + } + if ($status['exitcode'] !== 0) { + throw new \RuntimeException('A starter process failed'); + } +} +$startPhase = \microtime(true) - $startedAt; + +$service = ServiceClient::create($address); +$closedQuery = \sprintf('WorkflowId STARTS_WITH "%s" AND ExecutionStatus != "Running"', $prefix); +while ((int) $service->CountWorkflowExecutions( + (new CountWorkflowExecutionsRequest())->setNamespace(TEMPORAL_NAMESPACE)->setQuery($closedQuery), +)->getCount() < $workflows) { + if (\microtime(true) - $startedAt > $timeout) { + throw new \RuntimeException('Timeout: workflows did not complete'); + } + \usleep(200_000); +} +$clientWall = \microtime(true) - $startedAt; + +$starts = []; +$closes = []; +$latencies = []; +$failed = 0; +$token = ''; +do { + $page = $service->ListWorkflowExecutions( + (new ListWorkflowExecutionsRequest()) + ->setNamespace(TEMPORAL_NAMESPACE) + ->setQuery(\sprintf('WorkflowId STARTS_WITH "%s"', $prefix)) + ->setPageSize(1000) + ->setNextPageToken($token), + ); + foreach ($page->getExecutions() as $info) { + $start = seconds($info->getStartTime()); + $close = seconds($info->getCloseTime()); + $starts[] = $start; + $closes[] = $close; + $latencies[] = ($close - $start) * 1000; + if ($info->getStatus() !== WorkflowExecutionStatus::WORKFLOW_EXECUTION_STATUS_COMPLETED) { + $failed++; + } + } + $token = $page->getNextPageToken(); +} while ($token !== ''); +\sort($latencies); + +$wall = \max($closes) - \min($starts); +$result = [ + 'scenario' => $scenario, + 'workflows' => $workflows, + 'activities_per_workflow' => $activities, + 'param' => $param, + 'concurrency' => $concurrency, + 'rate' => $rate, + 'failed' => $failed, + 'wall_s' => \round($wall, 3), + 'workflows_per_s' => \round($workflows / $wall, 1), + 'activities_per_s' => \round($workflows * $activities / $wall, 1), + 'latency_ms_p50' => \round(percentile($latencies, 50), 1), + 'latency_ms_p95' => \round(percentile($latencies, 95), 1), + 'latency_ms_p99' => \round(percentile($latencies, 99), 1), + 'latency_ms_max' => \round(\end($latencies), 1), + 'start_phase_s' => \round($startPhase, 3), + 'start_rate_per_s' => \round($workflows / $startPhase, 1), + 'client_wall_s' => \round($clientWall, 3), + 'id_prefix' => $prefix, +]; + +\fprintf( + \STDERR, + "%s: %d wf x %d act, param %d | wall %.2fs | %.1f wf/s | %.1f act/s | p50 %.0fms p95 %.0fms p99 %.0fms | start phase %.2fs (%.0f/s) | failed %d\n", + $scenario, $workflows, $activities, $param, $result['wall_s'], $result['workflows_per_s'], $result['activities_per_s'], + $result['latency_ms_p50'], $result['latency_ms_p95'], $result['latency_ms_p99'], $startPhase, $result['start_rate_per_s'], $failed, +); +echo \json_encode($result), "\n"; diff --git a/bench/summary.sh b/bench/summary.sh new file mode 100755 index 000000000..28102ce3e --- /dev/null +++ b/bench/summary.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +FILE=${1:-$(dirname "$0")/matrix.jsonl} +echo "| scenario | label | runs | wf/s | act/s | p50 ms | p99 ms | worker CPU s | RSS MB | failed |" +echo "|---|---|---|---|---|---|---|---|---|---|" +jq -rs ' + group_by([.scenario, .workflows, .activities_per_workflow, .rate, .label]) + | map({ + sc: (.[0].scenario + " " + (.[0].workflows|tostring) + "x" + (.[0].activities_per_workflow|tostring) + + (if .[0].rate > 0 then " @" + (.[0].rate|tostring) + "/s" else "" end)), + label: .[0].label, + n: length, + wf: ([.[].workflows_per_s] | add / length * 10 | round / 10), + act: ([.[].activities_per_s] | add / length | round), + p50: ([.[].latency_ms_p50] | add / length | round), + p99: ([.[].latency_ms_p99] | add / length | round), + cpu: ([.[].worker.cpu_seconds] | add / length * 100 | round / 100), + rss: ([.[].worker.rss_mb_max] | add / length | round), + failed: ([.[].failed] | add) + }) + | sort_by(.sc, .label)[] + | "| \(.sc) | \(.label) | \(.n) | \(.wf) | \(.act) | \(.p50) | \(.p99) | \(.cpu) | \(.rss) | \(.failed) |" +' "$FILE" diff --git a/bench/worker.php b/bench/worker.php new file mode 100644 index 000000000..13a66628d --- /dev/null +++ b/bench/worker.php @@ -0,0 +1,44 @@ +newWorker(benchEnv('BENCH_TASK_QUEUE')) + ->registerWorkflowTypes(BenchWorkflow::class, BenchParallelWorkflow::class, BenchIoWorkflow::class, BenchCpuWorkflow::class, BenchKvWorkflow::class) + ->registerActivity(BenchActivity::class); +} + +function runRoadRunner(): void +{ + $factory = WorkerFactory::create(); + registerBench($factory); + $factory->run(); +} + +function runCore(): void +{ + $factory = CoreWorkerFactory::create(address: benchEnv('TEMPORAL_ADDRESS')); + registerBench($factory); + exit($factory->run()); +} + +$transport = benchEnv('BENCH_TRANSPORT'); +match ($transport) { + 'rr' => runRoadRunner(), + 'core', 'rr-core' => runCore(), + default => throw new \InvalidArgumentException("Unknown BENCH_TRANSPORT: {$transport}"), +}; diff --git a/composer.json b/composer.json index f5fc49b4c..93d80bec4 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "nesbot/carbon": "^3.8.4", "psr/log": "^2.0 || ^3.0.2", "ramsey/uuid": "^4.7.6", - "roadrunner-php/roadrunner-api-dto": "^1.15.0", + "roadrunner-php/roadrunner-api-dto": "dev-feature/coresdk-protos as 1.18.0", "roadrunner-php/version-checker": "^1.0.2", "spiral/attributes": "^3.1.8", "spiral/roadrunner": "^2025.1.5", @@ -44,15 +44,23 @@ "symfony/polyfill-php83": "^1.31.0", "symfony/process": "^6.4.33 || ^7.0 || ^8.0" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/xepozz/roadrunner-api-dto" + } + ], "autoload": { "psr-4": { "Temporal\\Api\\Testservice\\": "testing/api/testservice/Temporal/Api/Testservice", "GPBMetadata\\Temporal\\Api\\Testservice\\": "testing/api/testservice/GPBMetadata/Temporal/Api/Testservice", "Temporal\\Testing\\": "testing/src", - "Temporal\\": "src" + "Temporal\\": "src", + "Grpc\\": "core/grpc" }, "files": [ - "src/include.php" + "src/include.php", + "core/grpc/constants.php" ] }, "require-dev": { @@ -86,6 +94,9 @@ }, "suggest": { "ext-grpc": "For Client calls", + "ext-ffi": "For the worker without RoadRunner (sdk-core bridge) and for Client calls without ext-grpc", + "ext-pcntl": "For the worker without RoadRunner", + "ext-posix": "For the worker without RoadRunner", "ext-protobuf": "For better performance", "buggregator/trap": "For better debugging", "roadrunner/psr-logger": "RoadRunner PSR-3 logger integration", diff --git a/core/README.md b/core/README.md new file mode 100644 index 000000000..e34e9d506 --- /dev/null +++ b/core/README.md @@ -0,0 +1,162 @@ +# Temporal PHP worker without RoadRunner (sdk-core via FFI) + +This directory holds the native part of the RoadRunner-free worker transport. + +| path | content | +|---|---| +| `bridge/` | Rust `cdylib` over Temporal sdk-core (`temporalio-sdk-core` 0.9.0 from crates.io). Completion-queue C API, no callbacks into PHP. | +| `bridge/include/temporal_php_bridge.h` | The header that `FFI::cdef()` loads. | +| `grpc/` | The ext-grpc classes and constants (`Grpc\Channel`, `Grpc\Call`, `Grpc\Timeval`, `Grpc\ChannelCredentials`, `Grpc\CallCredentials`) on the bridge, loaded only without ext-grpc. | + +The PHP side is in `src/Worker/Core/` (worker, adapter) and `src/Internal/Bridge/` (FFI bridge, `TEMPORAL_CORE_*` env, connection options). + +The PHP classes for the `coresdk.*` protos (`Coresdk\...`) come from `roadrunner-php/roadrunner-api-dto`, generated from the same sdk-core release as the bridge (`sdk-core` submodule there, `core-v0.9.0`). Bump both together. + +## Build + +```bash +cd core/bridge && cargo build --release +``` + +The result is `core/bridge/target/release/libtemporal_php_bridge.dylib` (`.so` on Linux). +Set `TEMPORAL_CORE_BRIDGE_LIB` to use a library from another path. +PHP needs `ext-ffi` (the default `ffi.enable=preload` allows FFI in the CLI), `ext-protobuf` is recommended, `ext-pcntl` and `ext-posix` for the graceful stop. + +## Run a worker + +```php +use Temporal\Worker\Core\CoreWorkerFactory; + +$factory = CoreWorkerFactory::create( + address: '127.0.0.1:7233', + namespace: 'default', +); +$factory->newWorker('my-queue') + ->registerWorkflowTypes(MyWorkflow::class) + ->registerActivityImplementations(new MyActivity()); + +exit($factory->run()); +``` + +Run it with plain `php worker.php`. No `rr` binary and no `.rr.yaml` are necessary. The process serves all roles: workflows, local activities and activities. To run several processes, see [Run under RoadRunner](#run-under-roadrunner). + +| env | default | meaning | +|---|---|---| +| `TEMPORAL_ADDRESS` | `127.0.0.1:7233` | server address | +| `TEMPORAL_NAMESPACE` | `default` | namespace | +| `TEMPORAL_API_KEY` | none | API key, sent as `Authorization: Bearer `. Turns TLS on. `ServiceCredentials::withApiKey()` passed to `create()` has priority. `$factory->updateApiKey($key)` replaces it at run time, as the RoadRunner `temporal.UpdateAPIKey` RPC: the worker polls send the new key. A `\Stringable` key is read again at least every 0.5 s in each worker process | +| `TEMPORAL_TLS` | off | `true` turns TLS on with the system root certificates, `false` turns it off also with an API key | +| `TEMPORAL_TLS_SERVER_CA_CERT_PATH` / `_DATA` | system roots | server root CA (PEM file or PEM text). Turns TLS on | +| `TEMPORAL_TLS_CLIENT_CERT_PATH` / `_DATA` | none | client certificate for mTLS (PEM) | +| `TEMPORAL_TLS_CLIENT_KEY_PATH` / `_DATA` | none | client private key for mTLS (PEM) | +| `TEMPORAL_TLS_SERVER_NAME` | host of the address | server name for the certificate check | +| `TEMPORAL_PROFILE`, `TEMPORAL_CONFIG_FILE` | `default`, `temporal.toml` in the user config directory | TOML profile with the same settings (`Temporal\Common\EnvConfig\ConfigClient`). The env values override it. The `TEMPORAL_TLS*` and `TEMPORAL_API_KEY` values apply only with `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE` or a profile | +| `TEMPORAL_CORE_MAX_CACHED_WORKFLOWS` | from `memory_limit` | sticky cache size per workflow process. The default is (`memory_limit` − 64 MiB) / 64 KiB, from 10 to 10000 (`10000` with `memory_limit=-1`; 1024 with 128M), as TypeScript sizes `maxCachedWorkflows` from the heap limit: a cached workflow takes about 56 KB of PHP memory | +| `TEMPORAL_CORE_THREADS` | `1` | tokio worker threads per process, a positive integer (1 thread uses 15–22 % less CPU than one per core). Another value throws an exception | +| `TEMPORAL_CORE_GRPC_COMPRESSION` | `gzip` | `gzip` (sdk-core default) or `none`: gzip on the worker's gRPC calls saves network bytes and costs 7–15 % worker CPU. Another value stops the worker start with an error | +| `TEMPORAL_CORE_PROMETHEUS_ADDRESS` | off | `host:port` of the sdk-core Prometheus exporter (`/metrics`): the worker metrics and the `temporal_request*` and `temporal_long_request*` metrics of its gRPC connection. Each worker process takes the first free port from this one, so 1 workflow + 4 activity processes on `127.0.0.1:9464` serve `9464`–`9468` | +| `TEMPORAL_CORE_OTEL_URL` | off | OTLP collector URL for the same sdk-core metrics (`http://host:4317` for gRPC, `http://host:4318/v1/metrics` for HTTP). An alternative to `TEMPORAL_CORE_PROMETHEUS_ADDRESS`: both stop the worker start with an error | +| `TEMPORAL_CORE_OTEL_PROTOCOL` | `grpc` | `grpc` or `http` (OTLP/HTTP protobuf) | +| `TEMPORAL_CORE_OTEL_HEADERS` | none | `key=value,key=value` headers of every export request, for example an API key | +| `TEMPORAL_CORE_OTEL_METRIC_PERIODICITY_MS` | `1000` | interval of the OTLP metric export | +| `TEMPORAL_CORE_OTEL_USE_SECONDS_FOR_DURATIONS` | off | `true` exports durations as float seconds, not integer milliseconds | +| `TEMPORAL_CORE_METRIC_PREFIX` | `temporal_` | prefix of the sdk-core metric names (Prometheus and OTLP) | +| `TEMPORAL_CORE_METRIC_GLOBAL_TAGS` | none | `key=value,key=value` labels on every sdk-core metric (Prometheus and OTLP) | +| `TEMPORAL_CORE_POLLER_AUTOSCALING` | on | sdk-core scales the workflow and activity pollers from 1 up to the configured maximum, starting from 5 (burst latency −70 %); `0` keeps a fixed number of pollers | +| `TEMPORAL_CORE_TUNER_TARGET_MEMORY_USAGE`, `TEMPORAL_CORE_TUNER_TARGET_CPU_USAGE` | off (fixed slots) | target host memory and CPU usage from 0 to 1, set both: sdk-core's resource-based tuner gives out workflow and activity slots while the host stays below them. Local activities stay at 1 slot | +| `TEMPORAL_CORE_TUNER_WORKFLOW_MIN_SLOTS` / `_MAX_SLOTS` / `_RAMP_THROTTLE_MS` | `5` / `500` / `0` | workflow task slots of the resource-based tuner: always given, at most, minimum time between two new slots (Python and .NET defaults) | +| `TEMPORAL_CORE_TUNER_ACTIVITY_MIN_SLOTS` / `_MAX_SLOTS` / `_RAMP_THROTTLE_MS` | `1` / `500` / `50` | activity slots of the resource-based tuner; both are capped at 1, the process runs one activity at a time | +| `TEMPORAL_CORE_WORKER_HEARTBEAT_INTERVAL_MS` | `60000` | interval of the sdk-core worker heartbeats that the server shows in `temporal worker list` / `describe`, 1000–60000 (as in the other SDKs); `0` turns them off | +| `TEMPORAL_CORE_LOG` | `warn` | sdk-core log filter (`off`, `error`, `warn`, `info`, `debug` or a `tracing` filter); the records go to the worker's PSR logger with `target` and the core fields as context. At most 10000 records wait for the worker; more are dropped and a WARN record tells how many | + +The worker sends the `client-name: temporal-php-2` and `client-version: ` headers, as RoadRunner does. + +`SIGTERM`/`SIGINT` stops the process gracefully. Running activities get `WorkerOptions::$workerStopTimeout` to finish (0 by default, as in sdk-go), then they are cancelled. The exit code is non-zero when an sdk-core worker shut down unexpectedly. + +Mapped `WorkerOptions`: workflow/activity pollers and concurrency, `workerActivitiesPerSecond`, `taskQueueActivitiesPerSecond`, `stickyScheduleToStartTimeout`, `workerStopTimeout`, `disableWorkflowWorker`, `localActivityWorkerOnly`, `workflowPanicPolicy` (for panics in workflow code), `identity`, `buildID`, `deploymentOptions`. Worker plugins (`WorkerPluginInterface::run()`) wrap every worker process. + +## Run under RoadRunner + +The standard RoadRunner binary (`rr` from `dload.xml`) starts several worker processes with its `service` plugin, restarts them and serves KV, jobs, locks and metrics over RPC as before. Temporal still goes through sdk-core: the config has no `temporal` and no `server` section, so the RoadRunner Temporal plugin does not start. + +```yaml +version: "3" + +rpc: + listen: tcp://127.0.0.1:6001 + +kv: + cache: + driver: memory + config: {} + +service: + temporal-workflow: + command: "php worker.php" + process_num: 1 + remain_after_exit: true + restart_sec: 1 + env: + TEMPORAL_CORE_ROLE: workflow + RR_RPC: tcp://127.0.0.1:6001 + temporal-activity: + command: "php worker.php" + process_num: 4 + remain_after_exit: true + restart_sec: 1 + env: + TEMPORAL_CORE_ROLE: activity + RR_RPC: tcp://127.0.0.1:6001 +``` + +`worker.php` is the same script as above. `TEMPORAL_CORE_ROLE` (`workflow`, `activity` or `all`) tells `run()` the role of the process: a workflow process runs workflows and local activities, an activity process runs activities. Each workflow process has its own sticky cache. With `TEMPORAL_CORE_ROLE` set, `run()` serves only that role in the current process, stops when RoadRunner is gone and returns the exit code: `exit($factory->run())` is required. `remain_after_exit` restarts a process that exits. `rr serve` sends SIGINT on stop, and the worker shuts down gracefully within `timeout_stop_sec` (5 s by default). The RoadRunner PHP clients (`spiral/roadrunner-kv`, `spiral/roadrunner-jobs`, `roadrunner/psr-logger`) connect to `RR_RPC` from any of these processes. + +## gRPC client without ext-grpc + +When ext-grpc is not loaded, Composer autoloads the ext-grpc API from `core/grpc/` and defines the `Grpc\*` constants. The generated gRPC stubs of `grpc/grpc` then send their calls through the sdk-core bridge (tonic, rustls). `ServiceClient`, `OperatorClient`, `CloudClient`, the testing `TestService` and stubs that user code creates work without changes. As in ext-grpc, `ChannelCredentials::createSsl()` without root certificates uses the roots of `grpc/grpc` (`etc/roots.pem`). + +## Tests + +`TEMPORAL_WORKER_TRANSPORT=core` runs the Functional and Acceptance suites on this transport (`composer test:func`, `composer test:func-timeskip`, `composer test:accept`). Both start the worker processes under the standard `rr` (`tests/Functional/.rr.core.yaml`, `tests/Acceptance/.rr.core.yaml`). The `Core transport` CI workflow runs them without ext-grpc. + +## Benchmarks + +`bench/run.sh ` starts a worker (`rr`: RoadRunner with the Temporal plugin, `core`: one `php worker.php` process, `rr-core`: the standard `rr` starts the core worker processes with `bench/.rr.core.yaml`), a warmup and the measured run against a Temporal server, and appends one JSON line to the results file. `bench/matrix.sh` runs the whole matrix for `rr` and `rr-core`, `bench/summary.sh ` prints the table. + +| scenario | workflow | `` | +|---|---|---| +| `seq` | activities one after another | payload size, bytes | +| `par` | activities in parallel | payload size, bytes | +| `noact` | no activities | payload size, bytes | +| `io` | parallel activities that wait (`usleep`) | wait per activity, ms | +| `cpu` | CPU-bound workflow code, one activity | CPU time per activation, ms | +| `kv` | activities that set and get RoadRunner KV keys over RPC (`core` also starts a KV-only `rr` process and counts it) | KV set + get pairs per activity | + +| variable | default | meaning | +|---|---|---| +| `TEMPORAL_ADDRESS` | `127.0.0.1:7557` (the `bench/server` Docker server); `run.sh` sets it, `worker.php` and `starter.php` require it | server address | +| `BENCH_RATE` | `0` (as fast as possible) | workflows started per second | +| `BENCH_CONCURRENCY` | `8` | starter processes | +| `BENCH_WARMUP` | `20` | warmup workflows | +| `BENCH_TIMEOUT` | `600` | seconds for one run | +| `BENCH_ACTIVITY_WORKERS` | `4` | activity processes (RoadRunner pool size, `rr-core` activity processes) | +| `BENCH_WORKFLOW_WORKERS` | `1` | `rr-core` workflow processes | +| `BENCH_RESULTS` | `results.jsonl` | results file | +| `BENCH_LABEL` | the transport | row label in the summary | +| `BENCH_WORKER_PHP_FLAGS` | – | extra `php` flags for the core worker (for example `-dopcache.jit=tracing`) | +| `BENCH_RUSAGE` | – | macOS: path to the `bench/rusage.c` binary (`cc -O2 -o bench/rusage bench/rusage.c`); adds instructions and cycles of the worker processes to the results | +| `RR_BIN` | `../rr` | RoadRunner binary | +| `BENCH_RUNS`, `BENCH_RUN_TIMEOUT` | `2`, `600` | `matrix.sh` only: repetitions and the timeout per run (needs GNU `timeout`, `brew install coreutils` on macOS) | + +## Limitations and differences to RoadRunner + +- **History format.** sdk-go writes `Version`, `SideEffect` and `LocalActivity` markers, sdk-core writes `core_patch` and `core_local_activity`. A workflow started on RoadRunner cannot continue on core and the other way round. Drain running workflows (or use worker versioning / a new task queue) before the switch. +- **`Workflow::sideEffect()`** is a local activity that the worker completes itself: one more activation per call, one marker per call (also `uuid*()`). +- **`Workflow::getVersion()`** maps to patches with id `-` and a per-run cache. The `TemporalChangeVersion` search attribute that sdk-go upserts is not written. +- **Local activities** run in the workflow process, not in the activity processes. +- **`WorkflowPanicPolicy::FailWorkflow`** fails the workflow on panics and on non-determinism detected by sdk-core, but sdk-core fails the workflow only on the first attempt of the workflow task; a task that already timed out keeps failing (sdk-go fails it on any attempt). +- **Deadlock detection:** `WorkerOptions::$deadlockDetectionTimeout` has no effect. +- **One activity at a time:** a worker process runs one activity at a time. For parallel activities, start more activity processes under RoadRunner. +- **fork() after the runtime started:** the sdk-core runtime cannot be used in a process that forked after it started; `Bridge::shared()` throws a `LogicException` there. +- **gRPC client without ext-grpc:** a failed call returns the status code, the message and `grpc-status-details-bin`; other response headers and trailers are not returned. +- **Testing package:** the RR KV caches are not replaced. diff --git a/core/bridge/.gitignore b/core/bridge/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/core/bridge/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/core/bridge/Cargo.lock b/core/bridge/Cargo.lock new file mode 100644 index 000000000..89e0683e8 --- /dev/null +++ b/core/bridge/Cargo.lock @@ -0,0 +1,3532 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bon" +version = "3.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234655ec178edd82b891e262ea7cf71f6584bcd09eff94db786be23f1821825c" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.114", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "heck", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.114", + "tempfile", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-retry" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde5a672a61f96552aa5ed9fd9c81c3fbdae4be9b1e205d6eaf17c83705adc0f" +dependencies = [ + "futures", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockall" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", +] + +[[package]] +name = "opentelemetry-http" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "thiserror", + "tokio", + "tonic", + "tonic-types", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbjson" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8edd1efdd8ab23ba9cb9ace3d9987a72663d5d7c9f74fa00b51d6213645cf6c" +dependencies = [ + "base64 0.22.1", + "serde", +] + +[[package]] +name = "pbjson-build" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ed4d5c6ae95e08ac768883c8401cf0e8deb4e6e1d6a4e1fd3d2ec4f0ec63200" +dependencies = [ + "heck", + "itertools", + "prost", + "prost-types", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pid" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c931ef9756cd5e3fa3d395bfe09df4dfa6f0612c6ca8f6b12927d17ca34e36" +dependencies = [ + "num-traits", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "thiserror", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.114", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "prost-wkt" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3de5e9c9e84fcb5efa204b8e283d23e615a8bc8c777bf1d6622bb01dc61445" +dependencies = [ + "chrono", + "inventory", + "prost", + "serde", + "serde_derive", + "serde_json", + "typetag", +] + +[[package]] +name = "prost-wkt-build" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe500dc80e757a75e1e8fb7290e448d62dfba3105ece1d058579cb00b58151cd" +dependencies = [ + "heck", + "prost", + "prost-build", + "prost-types", + "quote", +] + +[[package]] +name = "prost-wkt-types" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13807eaa7e15833d06e899008371926201cdcd11d74b6d490f49130cdb3f415e" +dependencies = [ + "chrono", + "prost", + "prost-build", + "prost-types", + "prost-wkt", + "prost-wkt-build", + "regex", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ringbuf" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b123165531e325df9079f56c036f2da4103a5d5abfef9e2fda4b9c34d1ef7aa" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "temporal-php-bridge" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "cbindgen", + "futures-util", + "h2", + "hyper-util", + "libc", + "mimalloc", + "prost", + "serde", + "serde_json", + "temporalio-client", + "temporalio-common", + "temporalio-sdk-core", + "tokio", + "tonic", + "tower", + "tracing", +] + +[[package]] +name = "temporalio-client" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ca0a58c70ef0339e70ce03c273c5279f563266b48508fd883715bbd2a51396" +dependencies = [ + "anyhow", + "async-trait", + "backon", + "base64 0.23.1", + "bon", + "bytes", + "derive_more", + "dyn-clone", + "futures-retry", + "futures-util", + "http", + "http-body-util", + "hyper", + "hyper-util", + "parking_lot", + "rand 0.10.1", + "serde_json", + "temporalio-common", + "thiserror", + "tokio", + "tokio-rustls", + "tonic", + "tower", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "temporalio-common" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8ed18abe448ad30def8136a2a04ca70a6e17ec234540c72f54da2cc9deadc7" +dependencies = [ + "anyhow", + "async-trait", + "bon", + "crc32fast", + "derive_more", + "dirs", + "erased-serde", + "futures", + "futures-channel", + "http-body-util", + "hyper", + "hyper-util", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", + "parking_lot", + "prometheus", + "prost", + "prost-types", + "reqwest", + "ringbuf", + "rustls", + "serde", + "serde_json", + "temporalio-common-wasm", + "temporalio-protos", + "thiserror", + "tokio", + "toml 1.1.2+spec-1.1.0", + "tonic", + "tracing", + "tracing-core", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "temporalio-common-wasm" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de457a7799583535a50d146fe6ae716932dad60818843b9b56c33b742b4451a8" +dependencies = [ + "anyhow", + "async-trait", + "bon", + "chrono", + "crc32fast", + "derive_more", + "erased-serde", + "futures", + "parking_lot", + "prost", + "prost-wkt-types", + "serde", + "serde_json", + "temporalio-protos", + "thiserror", + "tracing", + "tracing-core", + "tracing-subscriber", + "url", +] + +[[package]] +name = "temporalio-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdf4ae71d667c43439431128c6235986ee378224f491ca5b90f1a0b094d5821" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "temporalio-protos" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf10c082ba2adf4073da05eb538146fce35734deb61f99d397ac3bd526c345d" +dependencies = [ + "anyhow", + "base64 0.23.1", + "derive_more", + "http", + "pbjson", + "pbjson-build", + "prost", + "prost-wkt-types", + "serde", + "serde_json", + "thiserror", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + +[[package]] +name = "temporalio-sdk-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5785769756f53e970bfe0ae5ebac87a4781d6e4b4323e2b3cc358a9dd971e6fd" +dependencies = [ + "anyhow", + "async-trait", + "backon", + "bon", + "crossbeam-channel", + "crossbeam-utils", + "derive_more", + "enum-iterator", + "enum_dispatch", + "futures", + "futures-util", + "gethostname", + "itertools", + "lru", + "mockall", + "opentelemetry-otlp", + "parking_lot", + "pid", + "pin-project", + "prost", + "prost-wkt-types", + "rand 0.10.1", + "reqwest", + "serde", + "serde_json", + "siphasher", + "slotmap", + "sysinfo", + "temporalio-client", + "temporalio-common", + "temporalio-macros", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.1", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "flate2", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.114", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "parking_lot", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/core/bridge/Cargo.toml b/core/bridge/Cargo.toml new file mode 100644 index 000000000..4afd7390b --- /dev/null +++ b/core/bridge/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "temporal-php-bridge" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +name = "temporal_php_bridge" +crate-type = ["cdylib"] + +[dependencies] +base64 = "0.22" +libc = "0.2" +mimalloc = { version = "0.1", default-features = false } +futures-util = "0.3" +hyper-util = { version = "0.1", default-features = false, features = ["tokio"] } +prost = "0.14" +serde = { version = "1", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", default-features = false, features = ["macros", "net", "rt", "sync", "time"] } +tonic = { version = "0.14", default-features = false, features = ["channel", "codegen", "tls-ring", "tls-native-roots"] } +temporalio-sdk-core = "0.9.0" +temporalio-client = "1.0.0" +temporalio-common = { version = "1.0.0", features = ["core-based-sdk", "otel"] } +tower = { version = "0.5", default-features = false, features = ["util"] } + +[dev-dependencies] +h2 = "0.4" +tracing = "0.1" + +[build-dependencies] +cbindgen = { version = "0.29", default-features = false } + +[profile.release] +strip = "debuginfo" +lto = "fat" +codegen-units = 1 diff --git a/core/bridge/build.rs b/core/bridge/build.rs new file mode 100644 index 000000000..840c5a35c --- /dev/null +++ b/core/bridge/build.rs @@ -0,0 +1,15 @@ +fn main() -> Result<(), Box> { + let crate_dir = std::env::var("CARGO_MANIFEST_DIR")?; + cbindgen::Builder::new() + .with_crate(crate_dir) + .with_config(cbindgen::Config { + language: cbindgen::Language::C, + no_includes: true, + usize_is_size_t: true, + documentation: false, + ..Default::default() + }) + .generate()? + .write_to_file("include/temporal_php_bridge.h"); + Ok(()) +} diff --git a/core/bridge/include/temporal_php_bridge.h b/core/bridge/include/temporal_php_bridge.h new file mode 100644 index 000000000..649ce3842 --- /dev/null +++ b/core/bridge/include/temporal_php_bridge.h @@ -0,0 +1,106 @@ +#define KIND_WORKFLOW_ACTIVATION 1 + +#define KIND_ACTIVITY_TASK 2 + +#define KIND_WORKFLOW_COMPLETED 3 + +#define KIND_ACTIVITY_COMPLETED 4 + +#define KIND_SHUTDOWN_FINALIZED 5 + +#define KIND_RPC_RESULT 6 + +#define KIND_LOG 7 + +#define STATUS_OK 0 + +#define STATUS_ERROR 1 + +#define STATUS_SHUTDOWN 2 + +#define CALL_OK 0 + +#define CALL_FAILED 1 + +typedef struct TpbClient TpbClient; + +typedef struct TpbRuntime TpbRuntime; + +typedef struct TpbWorker TpbWorker; + +typedef struct TpbEvent { + uint64_t tag; + int32_t kind; + int32_t status; + uint8_t *data; + size_t len; +} TpbEvent; + +struct TpbClient *tpb_client_new(struct TpbRuntime *rt, + const char *config, + size_t config_len, + uint8_t **err, + size_t *err_len); + +void tpb_client_call(struct TpbClient *c, + uint64_t tag, + const char *path, + size_t path_len, + const char *body, + size_t body_len, + const char *metadata, + size_t metadata_len, + uint64_t timeout_ms); + +void tpb_client_connect(struct TpbClient *c, uint64_t tag, uint64_t timeout_ms); + +void tpb_client_free(struct TpbClient *c); + +void tpb_bytes_free(uint8_t *data, size_t len); + +size_t tpb_next_events(struct TpbRuntime *rt, + uint32_t timeout_ms, + struct TpbEvent *out, + size_t max); + +struct TpbWorker *tpb_replayer_new(struct TpbRuntime *rt, + const char *config, + size_t config_len, + const char *history, + size_t history_len, + const char *workflow_id, + size_t workflow_id_len, + uint8_t **err, + size_t *err_len); + +struct TpbRuntime *tpb_runtime_new(const char *config, + size_t config_len, + uint8_t **err, + size_t *err_len); + +struct TpbWorker *tpb_worker_new(struct TpbRuntime *rt, + const char *config, + size_t config_len, + uint8_t **err, + size_t *err_len); + +void tpb_poll_workflow_activation(struct TpbWorker *w, uint64_t tag); + +void tpb_poll_activity_task(struct TpbWorker *w, uint64_t tag); + +void tpb_complete_workflow_activation(struct TpbWorker *w, + uint64_t tag, + const char *data, + size_t len); + +void tpb_complete_activity_task(struct TpbWorker *w, uint64_t tag, const char *data, size_t len); + +int32_t tpb_record_activity_heartbeat(struct TpbWorker *w, const char *data, size_t len); + +int32_t tpb_worker_set_api_key(struct TpbWorker *w, const char *api_key, size_t len); + +int32_t tpb_worker_initiate_shutdown(struct TpbWorker *w); + +void tpb_worker_finalize_shutdown(struct TpbWorker *w, uint64_t tag); + +void tpb_worker_free(struct TpbWorker *w); diff --git a/core/bridge/rust-toolchain.toml b/core/bridge/rust-toolchain.toml new file mode 100644 index 000000000..6360c1888 --- /dev/null +++ b/core/bridge/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.95.0" +components = ["rustfmt", "clippy"] diff --git a/core/bridge/src/client.rs b/core/bridge/src/client.rs new file mode 100644 index 000000000..142d1c184 --- /dev/null +++ b/core/bridge/src/client.rs @@ -0,0 +1,776 @@ +use crate::config::{ClientJson, parse}; +use crate::ffi::{KIND_RPC_RESULT, bytes, construct, guard, object, release, required}; +use crate::queue::Queue; +use crate::runtime::TpbRuntime; +use base64::{Engine, prelude::BASE64_STANDARD}; +use hyper_util::rt::TokioIo; +use prost::bytes::{Buf, BufMut}; +use std::{ + collections::HashMap, + future::Future, + io, + sync::{Arc, Mutex, PoisonError}, + time::Duration, +}; +use temporalio_client::ClientKeepAliveOptions; +use tokio::{net::UnixStream, sync::watch, time::Instant}; +use tonic::{ + Code, Request, Status, TimeoutExpired, + client::Grpc, + codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder}, + codegen::http::uri::PathAndQuery, + metadata::{AsciiMetadataKey, AsciiMetadataValue, BinaryMetadataKey, BinaryMetadataValue}, + transport::{Channel, Endpoint, Uri}, +}; +use tower::{Service, service_fn}; + +const MAX_GRPC_TIMEOUT: Duration = Duration::from_secs(99_999_999 * 60 * 60); +const CHANNEL_CLOSED: &str = "Channel is closed"; + +pub struct TpbClient { + target: Target, + channel: Arc>, + queue: Arc, + open: watch::Sender<()>, +} + +impl TpbClient { + fn push(&self, tag: u64, (grpc_code, data): (i32, Vec)) { + self.queue.push(tag, KIND_RPC_RESULT, grpc_code, data) + } + + fn push_panic(&self, tag: u64) -> impl FnOnce(String) + '_ { + move |message| self.push(tag, internal_error(message)) + } + + fn spawn( + &self, + tag: u64, + task: impl Future, Status>> + Send + 'static, + ) { + let queue = self.queue.clone(); + let mut open = self.open.subscribe(); + self.queue + .spawn(tag, KIND_RPC_RESULT, internal_error, async move { + let result = tokio::select! { + result = task => result, + _ = open.changed() => Err(Status::cancelled(CHANNEL_CLOSED)), + }; + let (grpc_code, data) = grpc_result(result); + queue.push(tag, KIND_RPC_RESULT, grpc_code, data); + }); + } +} + +#[derive(Clone, Copy)] +struct RawCodec; + +impl Codec for RawCodec { + type Encode = Vec; + type Decode = Vec; + type Encoder = RawCodec; + type Decoder = RawCodec; + + fn encoder(&mut self) -> Self::Encoder { + RawCodec + } + + fn decoder(&mut self) -> Self::Decoder { + RawCodec + } +} + +impl Encoder for RawCodec { + type Item = Vec; + type Error = Status; + + fn encode(&mut self, item: Vec, dst: &mut EncodeBuf<'_>) -> Result<(), Status> { + dst.put_slice(&item); + Ok(()) + } +} + +impl Decoder for RawCodec { + type Item = Vec; + type Error = Status; + + fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result>, Status> { + let mut bytes = vec![0; src.remaining()]; + src.copy_to_slice(&mut bytes); + Ok(Some(bytes)) + } +} + +#[derive(Clone)] +struct Target { + endpoint: Endpoint, + socket: Option>, +} + +impl Target { + fn connect_lazy(&self) -> Channel { + match &self.socket { + None => self.endpoint.connect_lazy(), + Some(path) => self + .endpoint + .connect_with_connector_lazy(unix_socket(path.clone())), + } + } + + async fn connect(self) -> Result { + match self.socket { + None => self.endpoint.connect().await, + Some(path) => { + self.endpoint + .connect_with_connector(unix_socket(path)) + .await + } + } + } +} + +fn unix_socket( + path: Arc, +) -> impl Service< + Uri, + Response = TokioIo, + Error = io::Error, + Future = impl Future>> + Send, +> + Send ++ 'static { + service_fn(move |_: Uri| { + let path = path.clone(); + async move { UnixStream::connect(&*path).await.map(TokioIo::new) } + }) +} + +fn grpc_target(target_url: &str) -> (String, Option>) { + let Some((scheme, target)) = target_url.split_once("://") else { + return (target_url.to_owned(), None); + }; + if let Some(path) = target.strip_prefix("unix:") { + let path = path.strip_prefix("//").unwrap_or(path); + return (format!("{scheme}://localhost"), Some(path.into())); + } + let authority = match target.split_once(':') { + Some(("dns", name)) => name.strip_prefix("//").map_or(name, |name| { + name.split_once('/').map_or(name, |(_, name)| name) + }), + Some(("ipv4" | "ipv6", address)) => address, + _ => target, + }; + (format!("{scheme}://{authority}"), None) +} + +fn target(config: ClientJson) -> Result { + let (uri, socket) = grpc_target(&config.target_url); + let keep_alive = ClientKeepAliveOptions::default(); + let endpoint = Endpoint::from_shared(uri) + .map_err(|e| e.to_string())? + .connect_timeout(Duration::from_millis(config.connect_timeout_ms)) + .keep_alive_while_idle(true) + .http2_keep_alive_interval(keep_alive.interval) + .keep_alive_timeout(keep_alive.timeout); + let Some(tls) = config.tls else { + return Ok(Target { endpoint, socket }); + }; + let (tls, origin) = tls.client_config()?; + let endpoint = match origin { + Some(origin) => endpoint.origin(origin), + None => endpoint, + }; + let endpoint = endpoint.tls_config(tls).map_err(|e| e.to_string())?; + Ok(Target { endpoint, socket }) +} + +fn new_client(rt: &TpbRuntime, config: &[u8]) -> Result { + let target = target(parse(config, "client config")?)?; + let channel = { + let _guard = rt.queue.handle.enter(); + target.connect_lazy() + }; + Ok(TpbClient { + target, + channel: Arc::new(Mutex::new(channel)), + queue: rt.queue.clone(), + open: watch::Sender::new(()), + }) +} + +fn timeout(timeout_ms: u64) -> Option { + (timeout_ms > 0).then(|| Duration::from_millis(timeout_ms).min(MAX_GRPC_TIMEOUT)) +} + +fn request( + body: Vec, + metadata: &[u8], + timeout: Option, +) -> Result>, Status> { + let mut request = Request::new(body); + if let Some(timeout) = timeout { + request.set_timeout(timeout); + } + if metadata.is_empty() { + return Ok(request); + } + let metadata: HashMap> = + parse(metadata, "metadata").map_err(Status::invalid_argument)?; + let invalid = + |e: &dyn std::fmt::Display| Status::invalid_argument(format!("Invalid metadata: {e}")); + for (key, values) in metadata { + for value in values { + if key.ends_with("-bin") { + let name = + BinaryMetadataKey::from_bytes(key.as_bytes()).map_err(|e| invalid(&e))?; + let value = BASE64_STANDARD.decode(value).map_err(|e| invalid(&e))?; + request + .metadata_mut() + .append_bin(name, BinaryMetadataValue::from_bytes(&value)); + } else { + let name = AsciiMetadataKey::from_bytes(key.as_bytes()).map_err(|e| invalid(&e))?; + let value = AsciiMetadataValue::try_from(value).map_err(|e| invalid(&e))?; + request.metadata_mut().append(name, value); + } + } + } + Ok(request) +} + +async fn call( + channel: Channel, + path: PathAndQuery, + request: Request>, +) -> Result, Status> { + let mut grpc = Grpc::new(channel); + grpc.ready() + .await + .map_err(|e| Status::unavailable(e.to_string()))?; + Ok(grpc.unary(request, path, RawCodec).await?.into_inner()) +} + +async fn connect(target: Target, timeout: Duration) -> Result { + match tokio::time::timeout(timeout, target.connect()).await { + Err(_) => Err(Status::deadline_exceeded("Connection timeout expired")), + Ok(result) => result.map_err(|e| Status::from_error(e.into())), + } +} + +const DEADLINE_EXCEEDED: &str = "Deadline Exceeded"; + +async fn with_deadline( + call: impl Future, Status>>, + timeout: Option, +) -> Result, Status> { + let deadline = timeout.map(|timeout| Instant::now() + timeout); + let result = match deadline { + None => call.await, + Some(deadline) => tokio::time::timeout_at(deadline, call) + .await + .unwrap_or_else(|_| Err(Status::deadline_exceeded(DEADLINE_EXCEEDED))), + }; + let expired = deadline.is_some_and(|deadline| Instant::now() >= deadline); + result.map_err(|status| client_status(status, expired)) +} + +fn client_status(status: Status, expired: bool) -> Status { + if timed_out(&status) { + return Status::deadline_exceeded(DEADLINE_EXCEEDED); + } + match (status.code(), std::error::Error::source(&status)) { + (Code::Cancelled, Some(_)) if expired => Status::deadline_exceeded(DEADLINE_EXCEEDED), + (Code::Cancelled | Code::Unknown, Some(_)) => Status::unavailable(status.message()), + _ => status, + } +} + +fn timed_out(status: &Status) -> bool { + let mut source = std::error::Error::source(status); + while let Some(error) = source { + if error.is::() { + return true; + } + source = error.source(); + } + false +} + +fn status_bytes(status: &Status) -> Vec { + let message = status.message().as_bytes(); + let mut bytes = Vec::with_capacity(4 + message.len() + status.details().len()); + bytes.extend_from_slice(&(message.len() as u32).to_le_bytes()); + bytes.extend_from_slice(message); + bytes.extend_from_slice(status.details()); + bytes +} + +fn grpc_result(result: Result, Status>) -> (i32, Vec) { + match result { + Ok(bytes) => (Code::Ok as i32, bytes), + Err(status) => (status.code() as i32, status_bytes(&status)), + } +} + +fn internal_error(message: String) -> (i32, Vec) { + grpc_result(Err(Status::internal(message))) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_client_new( + rt: *mut TpbRuntime, + config: *const libc::c_char, + config_len: usize, + err: *mut *mut u8, + err_len: *mut usize, +) -> *mut TpbClient { + construct(err, err_len, || { + new_client(required(rt, "runtime")?, bytes(config, config_len)) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_client_call( + c: *mut TpbClient, + tag: u64, + path: *const libc::c_char, + path_len: usize, + body: *const libc::c_char, + body_len: usize, + metadata: *const libc::c_char, + metadata_len: usize, + timeout_ms: u64, +) { + let Some(c) = object(c) else { + return; + }; + let (path, body, metadata) = ( + bytes(path, path_len), + bytes(body, body_len), + bytes(metadata, metadata_len), + ); + let timeout = timeout(timeout_ms); + guard(c.push_panic(tag), || { + let prepared = PathAndQuery::try_from(path.to_vec()) + .map_err(|e| Status::invalid_argument(format!("Invalid RPC path: {e}"))) + .and_then(|path| Ok((path, request(body.to_vec(), metadata, timeout)?))); + let (path, request) = match prepared { + Ok(prepared) => prepared, + Err(status) => return c.push(tag, grpc_result(Err(status))), + }; + let channel = c + .channel + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + c.spawn(tag, with_deadline(call(channel, path, request), timeout)); + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_client_connect(c: *mut TpbClient, tag: u64, timeout_ms: u64) { + let Some(c) = object(c) else { + return; + }; + guard(c.push_panic(tag), || { + let target = c.target.clone(); + let channel = c.channel.clone(); + c.spawn(tag, async move { + let connected = connect(target, Duration::from_millis(timeout_ms)).await?; + *channel.lock().unwrap_or_else(PoisonError::into_inner) = connected; + Ok(Vec::new()) + }); + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_client_free(c: *mut TpbClient) { + release(c) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ffi::{bytes, tpb_bytes_free}; + use crate::testing::{Checked, Event, events, grpc_server, only, runtime}; + + const GET_SYSTEM_INFO: &[u8] = + b"/temporal.api.workflowservice.v1.WorkflowService/GetSystemInfo"; + + fn client(rt: *mut TpbRuntime, target_url: &str) -> *mut TpbClient { + let config = + format!(r#"{{"target_url":"{target_url}","tls":null,"connect_timeout_ms":1000}}"#); + let c = unsafe { + tpb_client_new( + rt, + config.as_ptr().cast(), + config.len(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert!(!c.is_null()); + c + } + + fn call(c: *mut TpbClient, tag: u64, path: &[u8], metadata: &[u8], timeout_ms: u64) { + unsafe { + tpb_client_call( + c, + tag, + path.as_ptr().cast(), + path.len(), + std::ptr::null(), + 0, + metadata.as_ptr().cast(), + metadata.len(), + timeout_ms, + ) + } + } + + fn message(data: &[u8]) -> &[u8] { + let mut length = [0; 4]; + length.copy_from_slice(&data[..4]); + let length = u32::from_le_bytes(length) as usize; + assert_eq!(data.len(), 4 + length); + &data[4..] + } + + #[test] + fn call_and_connect_succeed_against_a_grpc_server() -> Checked { + let rt = runtime(); + let c = client(rt, &grpc_server()?); + + call(c, 1, GET_SYSTEM_INFO, b"", 0); + call(c, 2, GET_SYSTEM_INFO, br#"{"client-name":["php"]}"#, 5_000); + unsafe { tpb_client_connect(c, 3, 5_000) }; + + let ok = Code::Ok as i32; + assert_eq!( + events(rt, 3), + vec![ + (1, KIND_RPC_RESULT, ok, vec![]), + (2, KIND_RPC_RESULT, ok, vec![]), + (3, KIND_RPC_RESULT, ok, vec![]), + ] + ); + unsafe { tpb_client_free(c) }; + release(rt); + Ok(()) + } + + fn unix_forwarder(socket: &std::path::Path, address: String) -> std::io::Result<()> { + let _ = std::fs::remove_file(socket); + let listener = std::os::unix::net::UnixListener::bind(socket)?; + listener.set_nonblocking(true)?; + std::thread::spawn(move || -> std::io::Result<()> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(async move { + let listener = tokio::net::UnixListener::from_std(listener)?; + loop { + let (mut unix, _) = listener.accept().await?; + let mut tcp = tokio::net::TcpStream::connect(&address).await?; + tokio::spawn(async move { + tokio::io::copy_bidirectional(&mut unix, &mut tcp).await + }); + } + }) + }); + Ok(()) + } + + #[test] + fn calls_reach_the_server_through_dns_ipv4_and_unix_targets() -> Checked { + let rt = runtime(); + let server = grpc_server()?; + let address = server.trim_start_matches("http://"); + let socket = std::env::temp_dir().join(format!("tpb-{}.sock", std::process::id())); + unix_forwarder(&socket, address.to_owned())?; + let socket = socket.display(); + let clients = [ + format!("http://dns:///{address}"), + format!("http://dns:{address}"), + format!("http://dns://127.0.0.53/{address}"), + format!("http://ipv4:{address}"), + format!("http://unix:{socket}"), + format!("http://unix://{socket}"), + ] + .map(|target| client(rt, &target)); + + for (tag, c) in (1..).zip(clients) { + call(c, tag, GET_SYSTEM_INFO, b"", 5_000); + } + unsafe { tpb_client_connect(clients[4], 7, 5_000) }; + + let ok = |tag| (tag, KIND_RPC_RESULT, Code::Ok as i32, vec![]); + assert_eq!(events(rt, 7), (1..=7).map(ok).collect::>()); + for c in clients { + unsafe { tpb_client_free(c) }; + } + release(rt); + Ok(()) + } + + #[test] + fn freeing_the_client_cancels_its_calls_in_flight() -> Checked { + let rt = runtime(); + let silent = std::net::TcpListener::bind("127.0.0.1:0")?; + let c = client(rt, &format!("http://{}", silent.local_addr()?)); + + call(c, 1, GET_SYSTEM_INFO, b"", 60_000); + std::thread::sleep(Duration::from_millis(300)); + unsafe { tpb_client_free(c) }; + + let [(tag, kind, grpc_code, data)] = only(events(rt, 1))?; + assert_eq!( + (tag, kind, grpc_code), + (1, KIND_RPC_RESULT, Code::Cancelled as i32) + ); + assert_eq!(message(&data), CHANNEL_CLOSED.as_bytes()); + release(rt); + Ok(()) + } + + #[test] + fn call_with_an_invalid_path_or_metadata_reports_invalid_argument() -> Checked { + let rt = runtime(); + let c = client(rt, "http://127.0.0.1:1"); + + call(c, 7, b"no path", b"", 0); + call(c, 8, GET_SYSTEM_INFO, br#"{"bad key":["x"]}"#, 0); + + let invalid = |(tag, kind, grpc_code, data): Event, expected_tag: u64, prefix: &[u8]| { + let expected = (expected_tag, KIND_RPC_RESULT, Code::InvalidArgument as i32); + assert_eq!((tag, kind, grpc_code), expected); + assert!(message(&data).starts_with(prefix)); + }; + let [path, metadata] = only(events(rt, 2))?; + invalid(path, 7, b"Invalid RPC path: "); + invalid(metadata, 8, b"Invalid metadata: "); + unsafe { tpb_client_free(c) }; + release(rt); + Ok(()) + } + + #[test] + fn a_poisoned_channel_lock_keeps_the_client_working() -> Checked { + let rt = runtime(); + let c = client(rt, &grpc_server()?); + let channel = unsafe { &*c }.channel.clone(); + let poisoner = std::thread::spawn(move || { + let _locked = channel.lock(); + panic!("poisoned"); + }); + assert!(poisoner.join().is_err()); + + call(c, 1, GET_SYSTEM_INFO, b"", 0); + unsafe { tpb_client_connect(c, 2, 5_000) }; + call(c, 3, GET_SYSTEM_INFO, b"", 0); + + let ok = Code::Ok as i32; + assert_eq!( + events(rt, 3), + vec![ + (1, KIND_RPC_RESULT, ok, vec![]), + (2, KIND_RPC_RESULT, ok, vec![]), + (3, KIND_RPC_RESULT, ok, vec![]), + ] + ); + unsafe { tpb_client_free(c) }; + release(rt); + Ok(()) + } + + #[test] + fn null_runtime_and_client_pointers_do_not_crash() { + let (mut err, mut err_len) = (std::ptr::null_mut(), 0); + let config = br#"{"target_url":"http://127.0.0.1:1","tls":null,"connect_timeout_ms":1}"#; + let c = unsafe { + tpb_client_new( + std::ptr::null_mut(), + config.as_ptr().cast(), + config.len(), + &mut err, + &mut err_len, + ) + }; + assert!(c.is_null()); + assert_eq!(bytes(err.cast(), err_len), b"The runtime pointer is null"); + unsafe { tpb_bytes_free(err, err_len) }; + + call(c, 1, GET_SYSTEM_INFO, b"", 0); + unsafe { + tpb_client_connect(c, 2, 0); + tpb_client_free(c); + } + } + + #[test] + fn a_panic_becomes_an_internal_result() -> Checked { + let rt = runtime(); + let c = client(rt, "http://127.0.0.1:1"); + + guard(unsafe { &*c }.push_panic(4), || panic!("boom")); + + let [(tag, kind, grpc_code, data)] = only(events(rt, 1))?; + assert_eq!( + (tag, kind, grpc_code), + (4, KIND_RPC_RESULT, Code::Internal as i32) + ); + assert_eq!(message(&data), b"Panic in temporal-php-bridge: boom"); + unsafe { tpb_client_free(c) }; + release(rt); + Ok(()) + } + + #[test] + fn endpoint_reads_tls_and_rejects_an_invalid_url() { + let endpoint_of = |target_url: &str, tls: &str| { + let json = + format!(r#"{{"target_url":"{target_url}","tls":{tls},"connect_timeout_ms":1}}"#); + parse(json.as_bytes(), "client config").and_then(target) + }; + let tls = |domain: &str| { + format!( + r#"{{"server_root_ca_cert":null,"domain":{domain},"client_cert":null,"client_private_key":null}}"# + ) + }; + + assert!(endpoint_of("https://127.0.0.1:7233", &tls(r#""example.com""#)).is_ok()); + assert!(endpoint_of("https://127.0.0.1:7233", &tls("null")).is_ok()); + assert!(endpoint_of("not a url", "null").is_err()); + } + + #[test] + fn request_reads_ascii_and_base64_binary_metadata() -> Checked { + let metadata = br#"{"Client-Name":["php"],"trace-bin":["AAEC"]}"#; + let prepared = request(vec![1], metadata, timeout(1500))?; + let client_name = prepared.metadata().get("client-name"); + assert_eq!(client_name.and_then(|v| v.to_str().ok()), Some("php")); + let trace = prepared.metadata().get_bin("trace-bin"); + let trace = trace.and_then(|v| v.to_bytes().ok()); + assert_eq!(trace.as_deref(), Some(&[0, 1, 2][..])); + assert!(request(vec![], br#"{"bad key":["x"]}"#, None).is_err()); + assert!(request(vec![], br#"{"key":"x"}"#, None).is_err()); + assert!(request(vec![], br#"{"key":[1]}"#, None).is_err()); + Ok(()) + } + + #[test] + fn timeout_is_capped_to_the_longest_grpc_timeout() -> Checked { + assert_eq!(timeout(0), None); + assert_eq!(timeout(1500), Some(Duration::from_millis(1500))); + assert_eq!(timeout(u64::MAX), Some(MAX_GRPC_TIMEOUT)); + let capped = request(vec![], b"", timeout(u64::MAX))?; + let grpc_timeout = capped.metadata().get("grpc-timeout"); + assert_eq!( + grpc_timeout.and_then(|v| v.to_str().ok()), + Some("99999999H") + ); + Ok(()) + } + + #[test] + fn expired_deadline_is_deadline_exceeded() -> Checked { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build()?; + let timeout = Some(Duration::from_millis(10)); + let pending = rt.block_on(with_deadline(std::future::pending(), timeout)); + assert_eq!( + pending.err().map(|s| s.code()), + Some(Code::DeadlineExceeded) + ); + let expired_status = Status::from_error(Box::new(TimeoutExpired(()))); + let transport_error = std::io::Error::other(expired_status); + let expired = rt.block_on(with_deadline( + async { Err(Status::from_error(Box::new(transport_error))) }, + timeout, + )); + let expired = expired.err().map(|s| (s.code(), s.message().to_owned())); + assert_eq!( + expired, + Some((Code::DeadlineExceeded, DEADLINE_EXCEEDED.to_owned())) + ); + let cancelled = rt.block_on(with_deadline( + async { Err(Status::cancelled("cancelled by the server")) }, + timeout, + )); + assert_eq!(cancelled.err().map(|s| s.code()), Some(Code::Cancelled)); + Ok(()) + } + + #[test] + fn stream_cancelled_by_the_server_after_the_deadline_exceeds_it() -> Checked { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build()?; + let timeout = Duration::from_millis(10); + let reset = || Status::from_error(Box::new(h2::Error::from(h2::Reason::CANCEL))); + let after_deadline = rt.block_on(with_deadline( + async move { + std::thread::sleep(timeout); + Err(reset()) + }, + Some(timeout), + )); + assert_eq!( + after_deadline + .err() + .map(|s| (s.code(), s.message().to_owned())), + Some((Code::DeadlineExceeded, DEADLINE_EXCEEDED.to_owned())) + ); + let before_deadline = rt.block_on(with_deadline(async { Err(reset()) }, Some(timeout))); + assert_eq!( + before_deadline.err().map(|s| s.code()), + Some(Code::Unavailable) + ); + Ok(()) + } + + #[test] + fn transport_failures_are_unavailable_and_server_statuses_are_kept() { + let transport = |error: std::io::Error| { + client_status(Status::from_error(Box::new(error)), false).code() + }; + let reset = std::io::Error::other("connection reset"); + assert_eq!(transport(reset), Code::Unavailable); + let canceled = std::io::Error::other(Status::cancelled("operation was canceled")); + assert_eq!(transport(canceled), Code::Unavailable); + for code in [Code::Cancelled, Code::Unknown, Code::Internal] { + assert_eq!( + client_status(Status::new(code, "server"), true).code(), + code + ); + } + } + + #[test] + fn connect_reports_unavailable_and_timeout() -> Checked { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let tcp = |uri| Target { + endpoint: Endpoint::from_static(uri), + socket: None, + }; + let refused = rt.block_on(connect(tcp("http://127.0.0.1:1"), Duration::from_secs(5))); + assert_eq!(refused.err().map(|s| s.code()), Some(Code::Unavailable)); + let timeout = rt.block_on(connect(tcp("http://192.0.2.1:7233"), Duration::ZERO)); + assert_eq!( + timeout.err().map(|s| s.code()), + Some(Code::DeadlineExceeded) + ); + Ok(()) + } + + #[test] + fn status_bytes_prefix_the_message_length() { + let status = Status::with_details(Code::NotFound, "gone", vec![9, 9].into()); + assert_eq!( + status_bytes(&status), + [4, 0, 0, 0, b'g', b'o', b'n', b'e', 9, 9] + ); + } +} diff --git a/core/bridge/src/config.rs b/core/bridge/src/config.rs new file mode 100644 index 000000000..0b46d8de6 --- /dev/null +++ b/core/bridge/src/config.rs @@ -0,0 +1,678 @@ +use serde::{Deserialize, de::DeserializeOwned}; +use serde_json::{Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::time::Duration; +use temporalio_client::{ClientTlsOptions, ConnectionOptions, GrpcCompression, TlsOptions}; +use temporalio_common::{ + protos::temporal::api::enums::v1::VersioningBehavior, + telemetry::{OtelCollectorOptions, OtlpProtocol, metrics::TemporalMeter}, + worker::{WorkerDeploymentOptions, WorkerDeploymentVersion, WorkerTaskTypes}, +}; +use temporalio_sdk_core::{ + PollerBehavior, ResourceBasedSlotsOptions, ResourceBasedTunerConfig, ResourceSlotOptions, + SlotKind, SlotSupplierOptions, TunerHolderOptions, Url, WorkerConfig, WorkerTuner, + WorkerVersioningStrategy, WorkflowErrorType, +}; +use tonic::transport::{Certificate, ClientTlsConfig, Identity, Uri}; + +const AUTOSCALING_MIN_POLLERS: usize = 1; +const AUTOSCALING_INITIAL_POLLERS: usize = 5; + +pub fn parse(json: &[u8], what: &str) -> Result { + serde_json::from_slice(json).map_err(|e| format!("Invalid {what} JSON: {e}")) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeJson { + pub threads: NonZeroUsize, + pub log: String, + pub prometheus: Option, + pub otel: Option, + pub metric_prefix: Option, + pub global_tags: Option>, + pub worker_heartbeat_interval_ms: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum OtelProtocol { + Grpc, + Http, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OtelJson { + url: String, + headers: Option>, + metric_periodicity_ms: u64, + protocol: OtelProtocol, + use_seconds_for_durations: bool, +} + +impl OtelJson { + pub fn options( + self, + global_tags: Option>, + ) -> Result { + Ok(OtelCollectorOptions::builder() + .url( + Url::parse(&self.url) + .map_err(|e| format!("Invalid OpenTelemetry URL {}: {e}", self.url))?, + ) + .maybe_headers(self.headers) + .metric_periodicity(Duration::from_millis(self.metric_periodicity_ms)) + .protocol(match self.protocol { + OtelProtocol::Grpc => OtlpProtocol::Grpc, + OtelProtocol::Http => OtlpProtocol::Http, + }) + .use_seconds_for_durations(self.use_seconds_for_durations) + .maybe_global_tags(global_tags) + .build()) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TlsJson { + server_root_ca_cert: Option, + domain: Option, + client_cert: Option, + client_private_key: Option, +} + +fn client_identity( + client_cert: Option, + client_private_key: Option, +) -> Result, String> { + match (client_cert, client_private_key) { + (Some(cert), Some(key)) => Ok(Some((cert, key))), + (None, None) => Ok(None), + _ => Err("client_cert and client_private_key must be set together".into()), + } +} + +impl TlsJson { + fn options(self) -> Result { + let identity = client_identity(self.client_cert, self.client_private_key)?; + Ok(TlsOptions::builder() + .maybe_server_root_ca_cert(self.server_root_ca_cert.map(String::into_bytes)) + .maybe_domain(self.domain) + .maybe_client_tls_options(identity.map(|(client_cert, client_private_key)| { + ClientTlsOptions::builder() + .client_cert(client_cert.into_bytes()) + .client_private_key(client_private_key.into_bytes()) + .build() + })) + .build()) + } + + pub fn client_config(self) -> Result<(ClientTlsConfig, Option), String> { + let identity = client_identity(self.client_cert, self.client_private_key)?; + let mut config = match self.server_root_ca_cert { + Some(ca) => ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca)), + None => ClientTlsConfig::new().with_native_roots(), + }; + let mut origin = None; + if let Some(domain) = self.domain { + origin = Some( + format!("https://{domain}") + .parse() + .map_err(|e| format!("Invalid TLS domain: {e}"))?, + ); + config = config.domain_name(domain); + } + if let Some((cert, key)) = identity { + config = config.identity(Identity::from_pem(cert, key)); + } + Ok((config, origin)) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum Compression { + Gzip, + None, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConnectionJson { + target_url: String, + client_name: String, + client_version: String, + identity: String, + api_key: Option, + tls: Option, + connect_timeout_ms: u64, + grpc_compression: Compression, +} + +impl ConnectionJson { + pub fn options( + self, + metrics_meter: Option, + ) -> Result { + Ok( + ConnectionOptions::new(Url::parse(&self.target_url).map_err(|e| e.to_string())?) + .client_name(self.client_name) + .client_version(self.client_version) + .identity(self.identity) + .maybe_api_key(self.api_key) + .maybe_tls_options(self.tls.map(TlsJson::options).transpose()?) + .connect_timeout(Duration::from_millis(self.connect_timeout_ms)) + .maybe_metrics_meter(metrics_meter) + .grpc_compression(match self.grpc_compression { + Compression::Gzip => GrpcCompression::Gzip, + Compression::None => GrpcCompression::None, + }) + .build(), + ) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientJson { + pub target_url: String, + pub tls: Option, + pub connect_timeout_ms: u64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "PascalCase")] +struct DeploymentVersionJson { + deployment_name: String, + build_id: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "PascalCase")] +struct DeploymentJson { + use_versioning: bool, + version: Option, + default_versioning_behavior: i32, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SlotsJson { + min_slots: usize, + max_slots: usize, + ramp_throttle_ms: u64, +} + +impl SlotsJson { + fn options(&self) -> SlotSupplierOptions { + SlotSupplierOptions::ResourceBased(ResourceSlotOptions::new( + self.min_slots, + self.max_slots, + Duration::from_millis(self.ramp_throttle_ms), + )) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TunerJson { + target_memory_usage: f64, + target_cpu_usage: f64, + workflow_slots: SlotsJson, + activity_slots: SlotsJson, +} + +impl TunerJson { + fn tuner( + &self, + local_activity_slots: usize, + ) -> Result, String> { + let tuner = TunerHolderOptions::builder() + .resource_based_config(ResourceBasedTunerConfig::Options( + ResourceBasedSlotsOptions::builder() + .target_mem_usage(self.target_memory_usage) + .target_cpu_usage(self.target_cpu_usage) + .build(), + )) + .workflow_slot_options(self.workflow_slots.options()) + .activity_slot_options(self.activity_slots.options()) + .local_activity_slot_options(SlotSupplierOptions::FixedSize { + slots: local_activity_slots, + }) + .build_tuner_holder() + .map_err(|e| e.to_string())?; + Ok(Arc::new(tuner)) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkerJson { + namespace: String, + task_queue: String, + workflows: bool, + local_activities: bool, + remote_activities: bool, + deployment: Option, + build_id: String, + graceful_shutdown_period_ms: u64, + max_worker_activities_per_second: Option, + max_task_queue_activities_per_second: Option, + max_cached_workflows: usize, + max_outstanding_workflow_tasks: usize, + max_outstanding_activities: usize, + max_outstanding_local_activities: usize, + max_concurrent_workflow_task_polls: usize, + max_concurrent_activity_task_polls: usize, + nonsticky_to_sticky_poll_ratio: f32, + sticky_queue_schedule_to_start_timeout_ms: u64, + nondeterminism_fails_workflow: bool, + max_heartbeat_throttle_interval_ms: Option, + poller_autoscaling: bool, + tuner: Option, +} + +impl WorkerJson { + pub fn parse_with_connection(json: &[u8]) -> Result<(String, ConnectionJson, Self), String> { + let mut fields: Map = parse(json, "worker config")?; + let connection = fields.remove("connection").unwrap_or_default(); + Ok(( + connection.to_string(), + ConnectionJson::deserialize(connection) + .map_err(|e| format!("Invalid connection config JSON: {e}"))?, + Self::deserialize(Value::Object(fields)) + .map_err(|e| format!("Invalid worker config JSON: {e}"))?, + )) + } + + pub fn worker_config(&self) -> Result { + let fixed_slots = self.tuner.is_none(); + WorkerConfig::builder() + .namespace(self.namespace.as_str()) + .task_queue(self.task_queue.as_str()) + .versioning_strategy(self.versioning_strategy()?) + .max_cached_workflows(self.max_cached_workflows) + .maybe_max_outstanding_workflow_tasks( + fixed_slots.then_some(self.max_outstanding_workflow_tasks), + ) + .maybe_max_outstanding_activities( + fixed_slots.then_some(self.max_outstanding_activities), + ) + .maybe_max_outstanding_local_activities( + fixed_slots.then_some(self.max_outstanding_local_activities), + ) + .maybe_tuner( + self.tuner + .as_ref() + .map(|tuner| tuner.tuner(self.max_outstanding_local_activities)) + .transpose()?, + ) + .workflow_task_poller_behavior( + self.poller_behavior(self.max_concurrent_workflow_task_polls), + ) + .activity_task_poller_behavior( + self.poller_behavior(self.max_concurrent_activity_task_polls), + ) + .maybe_max_heartbeat_throttle_interval( + self.max_heartbeat_throttle_interval_ms + .map(Duration::from_millis), + ) + .nonsticky_to_sticky_poll_ratio(self.nonsticky_to_sticky_poll_ratio) + .sticky_queue_schedule_to_start_timeout(Duration::from_millis( + self.sticky_queue_schedule_to_start_timeout_ms, + )) + .graceful_shutdown_period(Duration::from_millis(self.graceful_shutdown_period_ms)) + .maybe_max_worker_activities_per_second(self.max_worker_activities_per_second) + .maybe_max_task_queue_activities_per_second(self.max_task_queue_activities_per_second) + .workflow_failure_errors(if self.nondeterminism_fails_workflow { + HashSet::from([WorkflowErrorType::Nondeterminism]) + } else { + HashSet::new() + }) + .task_types(WorkerTaskTypes { + enable_workflows: self.workflows, + enable_local_activities: self.local_activities, + enable_remote_activities: self.remote_activities, + enable_nexus: false, + }) + .build() + } + + fn poller_behavior(&self, maximum: usize) -> PollerBehavior { + if self.poller_autoscaling { + PollerBehavior::Autoscaling { + minimum: AUTOSCALING_MIN_POLLERS, + maximum, + initial: maximum.min(AUTOSCALING_INITIAL_POLLERS), + } + } else { + PollerBehavior::SimpleMaximum(maximum) + } + } + + fn versioning_strategy(&self) -> Result { + let Some((deployment, version)) = self + .deployment + .as_ref() + .and_then(|d| d.version.as_ref().map(|v| (d, v))) + else { + return Ok(WorkerVersioningStrategy::None { + build_id: self.build_id.clone(), + }); + }; + let default_versioning_behavior = + match VersioningBehavior::try_from(deployment.default_versioning_behavior) + .map_err(|e| e.to_string())? + { + VersioningBehavior::Unspecified => None, + behavior => Some(behavior.into()), + }; + Ok(WorkerVersioningStrategy::WorkerDeploymentBased( + WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name(version.deployment_name.clone()) + .build_id(version.build_id.clone()) + .build(), + ) + .use_worker_versioning(deployment.use_versioning) + .maybe_default_versioning_behavior(default_versioning_behavior) + .build(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{Checked, worker_json}; + use serde_json::{Value, json}; + + fn worker() -> Value { + worker_json("http://127.0.0.1:7233") + } + + fn without(mut json: Value, key: &str) -> Value { + if let Some(fields) = json.as_object_mut() { + fields.remove(key); + } + json + } + + fn parse_worker(json: &Value) -> Result<(ConnectionJson, WorkerJson), String> { + WorkerJson::parse_with_connection(json.to_string().as_bytes()) + .map(|(_, connection, worker)| (connection, worker)) + } + + fn parse_error(json: &Value) -> String { + parse_worker(json).err().unwrap_or_default() + } + + fn worker_config(json: &Value) -> Result { + parse_worker(json)?.1.worker_config() + } + + #[test] + fn workers_with_the_same_connection_share_the_connection_key() -> Checked { + let first = worker(); + let mut second = worker(); + second["task_queue"] = json!("another-queue"); + let mut other = worker(); + other["connection"]["identity"] = json!("2@host"); + + let key = |json: &Value| { + WorkerJson::parse_with_connection(json.to_string().as_bytes()).map(|parsed| parsed.0) + }; + + assert_eq!(key(&first)?, key(&second)?); + assert_ne!(key(&first)?, key(&other)?); + Ok(()) + } + + fn tls(client_cert: Option<&str>, client_private_key: Option<&str>) -> TlsJson { + TlsJson { + server_root_ca_cert: None, + domain: None, + client_cert: client_cert.map(str::to_owned), + client_private_key: client_private_key.map(str::to_owned), + } + } + + #[test] + fn worker_config_maps_every_key() -> Checked { + let config = worker_config(&worker())?; + assert_eq!(config.namespace, "default"); + assert_eq!(config.task_queue, "q"); + assert_eq!(config.max_cached_workflows, 10000); + assert_eq!(config.max_outstanding_workflow_tasks, Some(100)); + assert_eq!(config.nonsticky_to_sticky_poll_ratio, 0.5); + assert_eq!( + config.sticky_queue_schedule_to_start_timeout, + Duration::from_millis(5000) + ); + assert_eq!( + config.graceful_shutdown_period, + Some(Duration::from_millis(1500)) + ); + assert_eq!(config.max_worker_activities_per_second, Some(2.5)); + assert_eq!(config.max_task_queue_activities_per_second, None); + assert!(config.workflow_failure_errors.is_empty()); + assert!(matches!( + config.workflow_task_poller_behavior, + Some(PollerBehavior::SimpleMaximum(8)) + )); + assert!(matches!( + config.versioning_strategy, + WorkerVersioningStrategy::None { .. } + )); + assert!(config.task_types.enable_workflows); + assert!(config.task_types.enable_local_activities); + assert!(!config.task_types.enable_remote_activities); + Ok(()) + } + + #[test] + fn worker_config_maps_autoscaling_heartbeat_throttle_and_nondeterminism() -> Checked { + let mut json = worker(); + json["poller_autoscaling"] = json!(true); + json["max_heartbeat_throttle_interval_ms"] = json!(60000); + json["nondeterminism_fails_workflow"] = json!(true); + + let config = worker_config(&json)?; + + assert!(matches!( + config.workflow_task_poller_behavior, + Some(PollerBehavior::Autoscaling { + minimum: 1, + maximum: 8, + initial: 5 + }) + )); + assert!(matches!( + config.activity_task_poller_behavior, + Some(PollerBehavior::Autoscaling { + minimum: 1, + maximum: 1, + initial: 1 + }) + )); + assert_eq!( + config.max_heartbeat_throttle_interval, + Duration::from_secs(60) + ); + assert_eq!( + config.workflow_failure_errors, + HashSet::from([WorkflowErrorType::Nondeterminism]) + ); + Ok(()) + } + + #[test] + fn worker_config_builds_a_resource_based_tuner_instead_of_fixed_slots() -> Checked { + let mut json = worker(); + json["tuner"] = json!({ + "target_memory_usage": 0.8, + "target_cpu_usage": 0.9, + "workflow_slots": {"min_slots": 5, "max_slots": 500, "ramp_throttle_ms": 0}, + "activity_slots": {"min_slots": 1, "max_slots": 4, "ramp_throttle_ms": 50}, + }); + + let config = worker_config(&json)?; + + assert_eq!( + ( + config.max_outstanding_workflow_tasks, + config.max_outstanding_activities, + config.max_outstanding_local_activities + ), + (None, None, None) + ); + let tuner = config.tuner.ok_or("no tuner")?; + assert_eq!( + tuner.workflow_task_slot_supplier().slot_supplier_kind(), + "ResourceBased" + ); + assert_eq!( + tuner.activity_task_slot_supplier().slot_supplier_kind(), + "ResourceBased" + ); + let local_activities = tuner.local_activity_slot_supplier(); + assert_eq!(local_activities.slot_supplier_kind(), "Fixed"); + assert_eq!(local_activities.available_slots(), Some(1)); + Ok(()) + } + + #[test] + fn worker_config_rejects_missing_unknown_and_mistyped_keys() { + assert!(parse_error(&without(worker(), "task_queue")).contains("task_queue")); + + let mut missing = worker(); + missing["connection"] = without(missing["connection"].take(), "client_name"); + assert!(parse_error(&missing).contains("client_name")); + + let mut unknown = worker(); + unknown["max_cached_workflow"] = json!(1); + assert!(parse_error(&unknown).contains("max_cached_workflow")); + + let mut mistyped = worker(); + mistyped["max_cached_workflows"] = json!("10"); + assert!(parse_worker(&mistyped).is_err()); + + let mut compression = worker(); + compression["connection"]["grpc_compression"] = json!("zstd"); + assert!(parse_error(&compression).contains("zstd")); + } + + #[test] + fn worker_config_reads_deployment() -> Checked { + let mut json = worker(); + json["deployment"] = json!({ + "UseVersioning": true, + "Version": {"DeploymentName": "app", "BuildId": "b1"}, + "DefaultVersioningBehavior": 2, + }); + let auto_upgrade = Some(VersioningBehavior::AutoUpgrade.into()); + assert!(matches!( + worker_config(&json)?.versioning_strategy, + WorkerVersioningStrategy::WorkerDeploymentBased(options) + if options.use_worker_versioning + && options.version.deployment_name == "app" + && options.version.build_id == "b1" + && options.default_versioning_behavior == auto_upgrade + )); + + json["deployment"]["DefaultVersioningBehavior"] = json!(0); + assert!(matches!( + worker_config(&json)?.versioning_strategy, + WorkerVersioningStrategy::WorkerDeploymentBased(options) + if options.default_versioning_behavior.is_none() + )); + + json["deployment"]["DefaultVersioningBehavior"] = json!(99); + assert!(worker_config(&json).is_err()); + Ok(()) + } + + #[test] + fn replayer_config_is_the_worker_part_without_connection() -> Checked { + let json = without(worker(), "connection"); + let config: WorkerJson = parse(json.to_string().as_bytes(), "worker config")?; + assert_eq!(config.worker_config()?.task_queue, "q"); + assert!(parse::(worker().to_string().as_bytes(), "worker config").is_err()); + assert!(parse_error(&json).contains("connection")); + Ok(()) + } + + #[test] + fn client_cert_and_key_must_be_set_together() -> Checked { + let error = Some("client_cert and client_private_key must be set together".to_owned()); + for (cert, key) in [(Some("cert"), None), (None, Some("key"))] { + assert_eq!(tls(cert, key).options().err(), error); + assert_eq!(tls(cert, key).client_config().err(), error); + } + assert!(tls(None, None).options()?.client_tls_options.is_none()); + assert!( + tls(Some("cert"), Some("key")) + .options()? + .client_tls_options + .is_some() + ); + assert!(tls(None, None).client_config().is_ok()); + assert!(tls(Some("cert"), Some("key")).client_config().is_ok()); + Ok(()) + } + + #[test] + fn client_tls_config_reads_the_ca_and_the_domain() -> Checked { + let with_domain = |domain: &str| TlsJson { + server_root_ca_cert: Some("ca".into()), + domain: Some(domain.into()), + ..tls(Some("cert"), Some("key")) + }; + + let (_, origin) = with_domain("example.com").client_config()?; + + assert_eq!( + origin.map(|o| o.to_string()).as_deref(), + Some("https://example.com/") + ); + assert!( + with_domain("bad domain") + .client_config() + .err() + .is_some_and(|e| e.starts_with("Invalid TLS domain: ")) + ); + Ok(()) + } + + #[test] + fn connection_options_read_tls_timeout_and_compression() -> Checked { + let mut json = worker(); + json["connection"]["tls"] = json!({ + "server_root_ca_cert": null, + "domain": "example.com", + "client_cert": null, + "client_private_key": null, + }); + json["connection"]["grpc_compression"] = json!("none"); + let options = parse_worker(&json)?.0.options(None)?; + assert_eq!(options.connect_timeout, Some(Duration::from_secs(10))); + assert_eq!(options.grpc_compression, GrpcCompression::None); + assert_eq!( + options.tls_options.and_then(|tls| tls.domain).as_deref(), + Some("example.com") + ); + Ok(()) + } + + #[test] + fn runtime_config_needs_threads() -> Checked { + let runtime: RuntimeJson = parse(br#"{"threads":2,"log":"off"}"#, "runtime")?; + assert_eq!(runtime.threads.get(), 2); + assert!(parse::(br#"{"log":"info"}"#, "runtime").is_err()); + assert!(parse::(br#"{"threads":"2","log":"off"}"#, "runtime").is_err()); + Ok(()) + } +} diff --git a/core/bridge/src/ffi.rs b/core/bridge/src/ffi.rs new file mode 100644 index 000000000..1518d016c --- /dev/null +++ b/core/bridge/src/ffi.rs @@ -0,0 +1,162 @@ +use std::{ + any::Any, + panic::{AssertUnwindSafe, catch_unwind}, +}; + +pub const KIND_WORKFLOW_ACTIVATION: i32 = 1; +pub const KIND_ACTIVITY_TASK: i32 = 2; +pub const KIND_WORKFLOW_COMPLETED: i32 = 3; +pub const KIND_ACTIVITY_COMPLETED: i32 = 4; +pub const KIND_SHUTDOWN_FINALIZED: i32 = 5; +pub const KIND_RPC_RESULT: i32 = 6; +pub const KIND_LOG: i32 = 7; + +pub const STATUS_OK: i32 = 0; +pub const STATUS_ERROR: i32 = 1; +pub const STATUS_SHUTDOWN: i32 = 2; + +pub const CALL_OK: i32 = 0; +pub const CALL_FAILED: i32 = 1; + +#[repr(C)] +pub struct TpbEvent { + pub tag: u64, + pub kind: i32, + pub status: i32, + pub data: *mut u8, + pub len: usize, +} + +pub fn into_raw_bytes(bytes: Box<[u8]>) -> (*mut u8, usize) { + if bytes.is_empty() { + return (std::ptr::null_mut(), 0); + } + let len = bytes.len(); + (Box::into_raw(bytes).cast(), len) +} + +pub fn object<'a, T>(ptr: *mut T) -> Option<&'a T> { + unsafe { ptr.as_ref() } +} + +pub fn required<'a, T>(ptr: *mut T, name: &str) -> Result<&'a T, String> { + object(ptr).ok_or_else(|| format!("The {name} pointer is null")) +} + +pub fn bytes<'a>(data: *const libc::c_char, len: usize) -> &'a [u8] { + if data.is_null() || len == 0 { + return &[]; + } + unsafe { std::slice::from_raw_parts(data.cast(), len) } +} + +pub fn events_out<'a>(out: *mut TpbEvent, max: usize) -> &'a mut [TpbEvent] { + if out.is_null() || max == 0 { + return &mut []; + } + unsafe { std::slice::from_raw_parts_mut(out, max) } +} + +pub fn panic_message(panic: Box) -> String { + let message = panic + .downcast_ref::<&str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or("unknown panic"); + format!("Panic in temporal-php-bridge: {message}") +} + +pub fn guard(fallback: impl FnOnce(String) -> T, body: impl FnOnce() -> T) -> T { + catch_unwind(AssertUnwindSafe(body)).unwrap_or_else(|panic| fallback(panic_message(panic))) +} + +pub fn call(ptr: *mut T, failed: R, body: impl FnOnce(&T) -> R) -> R { + guard(|_| failed, || object(ptr).map_or(failed, body)) +} + +pub fn construct( + err: *mut *mut u8, + err_len: *mut usize, + body: impl FnOnce() -> Result, +) -> *mut T { + let message = match catch_unwind(AssertUnwindSafe(body)) { + Ok(Ok(value)) => return Box::into_raw(Box::new(value)), + Ok(Err(message)) => message, + Err(panic) => panic_message(panic), + }; + if let (Some(err), Some(err_len)) = unsafe { (err.as_mut(), err_len.as_mut()) } { + (*err, *err_len) = into_raw_bytes(message.into_bytes().into_boxed_slice()); + } + std::ptr::null_mut() +} + +pub fn release(value: *mut T) { + guard( + |_| (), + || { + if !value.is_null() { + drop(unsafe { Box::from_raw(value) }); + } + }, + ) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_bytes_free(data: *mut u8, len: usize) { + guard( + |_| (), + || { + if !data.is_null() && len > 0 { + drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(data, len)) }); + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn construct_returns_errors_and_panics_through_the_error_pointers() { + let (mut err, mut err_len) = (std::ptr::null_mut(), 0); + let failed: Result = Err("no worker".into()); + assert!(construct(&mut err, &mut err_len, || failed).is_null()); + assert_eq!(bytes(err.cast(), err_len), b"no worker"); + unsafe { tpb_bytes_free(err, err_len) }; + + let panicked = construct(&mut err, &mut err_len, || -> Result { + panic!("boom") + }); + assert!(panicked.is_null()); + assert_eq!( + bytes(err.cast(), err_len), + b"Panic in temporal-php-bridge: boom" + ); + unsafe { tpb_bytes_free(err, err_len) }; + + let unreported: Result = Err("lost".into()); + let null = std::ptr::null_mut(); + assert!(construct(null, std::ptr::null_mut(), || unreported).is_null()); + } + + #[test] + fn panic_message_reads_str_and_string_payloads() { + let message = |payload: Box| panic_message(payload); + assert_eq!(message(Box::new("a")), "Panic in temporal-php-bridge: a"); + assert_eq!( + message(Box::new(String::from("b"))), + "Panic in temporal-php-bridge: b" + ); + assert_eq!( + message(Box::new(42)), + "Panic in temporal-php-bridge: unknown panic" + ); + } + + #[test] + fn freeing_null_does_nothing() { + unsafe { tpb_bytes_free(std::ptr::null_mut(), 0) }; + release(std::ptr::null_mut::()); + } +} diff --git a/core/bridge/src/lib.rs b/core/bridge/src/lib.rs new file mode 100644 index 000000000..80fe94bba --- /dev/null +++ b/core/bridge/src/lib.rs @@ -0,0 +1,14 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +mod client; +mod config; +mod ffi; +mod queue; +mod replay; +mod runtime; +#[cfg(test)] +mod testing; +mod worker; + +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; diff --git a/core/bridge/src/queue.rs b/core/bridge/src/queue.rs new file mode 100644 index 000000000..6734e1705 --- /dev/null +++ b/core/bridge/src/queue.rs @@ -0,0 +1,270 @@ +use crate::ffi::{ + KIND_LOG, STATUS_ERROR, STATUS_OK, STATUS_SHUTDOWN, TpbEvent, call, events_out, into_raw_bytes, + panic_message, +}; +use crate::runtime::TpbRuntime; +use futures_util::FutureExt; +use prost::Message; +use serde_json::Value; +use std::{ + collections::{HashMap, VecDeque}, + future::Future, + panic::AssertUnwindSafe, + sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}, + time::Duration, +}; +use temporalio_sdk_core::PollError; +use tokio::runtime::Handle; + +pub(crate) const MAX_PENDING_LOGS: usize = 10_000; + +struct Event { + tag: u64, + kind: i32, + status: i32, + data: Box<[u8]>, +} + +#[derive(Default)] +struct Events { + list: VecDeque, + logs: usize, + dropped_logs: u64, +} + +impl Events { + fn add(&mut self, tag: u64, kind: i32, status: i32, data: Vec) { + if kind == KIND_LOG { + self.logs += 1; + } + self.list.push_back(Event { + tag, + kind, + status, + data: data.into_boxed_slice(), + }); + } +} + +fn log_entry(level: &str, target: &str, message: &str, fields: &HashMap) -> Vec { + serde_json::json!({ + "level": level, + "target": target, + "message": message, + "fields": fields, + }) + .to_string() + .into_bytes() +} + +pub struct Queue { + pub handle: Handle, + events: Mutex, + ready: Condvar, +} + +impl Queue { + pub fn new(handle: Handle) -> Self { + Self { + handle, + events: Mutex::new(Events::default()), + ready: Condvar::new(), + } + } + + fn events(&self) -> MutexGuard<'_, Events> { + self.events.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub fn push(&self, tag: u64, kind: i32, status: i32, data: Vec) { + let mut events = self.events(); + events.add(tag, kind, status, data); + self.wake(events); + } + + pub fn push_log( + &self, + level: &str, + target: &str, + message: &str, + fields: &HashMap, + ) { + let entry = log_entry(level, target, message, fields); + let mut events = self.events(); + let dropped = events.dropped_logs; + if events.logs + usize::from(dropped > 0) >= MAX_PENDING_LOGS { + events.dropped_logs += 1; + return; + } + if dropped > 0 { + let notice = format!("{dropped} log records were dropped: the event queue was full"); + let notice = log_entry("WARN", env!("CARGO_CRATE_NAME"), ¬ice, &HashMap::new()); + events.add(0, KIND_LOG, STATUS_OK, notice); + events.dropped_logs = 0; + } + events.add(0, KIND_LOG, STATUS_OK, entry); + self.wake(events); + } + + fn wake(&self, events: MutexGuard<'_, Events>) { + drop(events); + self.ready.notify_one(); + } + + pub fn push_error(&self, tag: u64, kind: i32, result: Result<(), String>) { + if let Err(message) = result { + self.push(tag, kind, STATUS_ERROR, message.into_bytes()); + } + } + + pub fn push_poll(&self, tag: u64, kind: i32, result: Result) { + match result { + Ok(message) => self.push(tag, kind, STATUS_OK, message.encode_to_vec()), + Err(PollError::ShutDown) => self.push(tag, kind, STATUS_SHUTDOWN, Vec::new()), + Err(err) => self.push(tag, kind, STATUS_ERROR, err.to_string().into_bytes()), + } + } + + pub fn spawn( + self: &Arc, + tag: u64, + kind: i32, + on_panic: fn(String) -> (i32, Vec), + task: impl Future + Send + 'static, + ) { + let queue = self.clone(); + let task = AssertUnwindSafe(task).catch_unwind(); + self.handle.spawn(async move { + if let Err(panic) = task.await { + let (status, data) = on_panic(panic_message(panic)); + queue.push(tag, kind, status, data); + } + }); + } + + fn take(&self, timeout_ms: u32, out: &mut [TpbEvent]) -> usize { + let mut events = self.events(); + if events.list.is_empty() && timeout_ms > 0 { + let timeout = Duration::from_millis(timeout_ms.into()); + events = self + .ready + .wait_timeout_while(events, timeout, |e| e.list.is_empty()) + .unwrap_or_else(PoisonError::into_inner) + .0; + } + let events = &mut *events; + let count = events.list.len().min(out.len()); + for (slot, event) in out.iter_mut().zip(events.list.drain(..count)) { + if event.kind == KIND_LOG { + events.logs -= 1; + } + let (data, len) = into_raw_bytes(event.data); + *slot = TpbEvent { + tag: event.tag, + kind: event.kind, + status: event.status, + data, + len, + }; + } + count + } +} + +pub fn error_status(message: String) -> (i32, Vec) { + (STATUS_ERROR, message.into_bytes()) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_next_events( + rt: *mut TpbRuntime, + timeout_ms: u32, + out: *mut TpbEvent, + max: usize, +) -> usize { + call(rt, 0, |rt| rt.queue.take(timeout_ms, events_out(out, max))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ffi::{KIND_ACTIVITY_TASK, release}; + use crate::testing::{Checked, empty_events, events, only, runtime}; + use temporalio_common::protos::coresdk::activity_task::ActivityTask; + + #[test] + fn take_fills_at_most_the_buffer_and_returns_at_once_without_timeout() { + let rt = runtime(); + let queue = &unsafe { &*rt }.queue; + let mut out: [TpbEvent; 2] = empty_events(); + assert_eq!(queue.take(0, &mut out), 0); + + for tag in 1..=3 { + queue.push(tag, 0, STATUS_OK, Vec::new()); + } + assert_eq!(queue.take(0, &mut out), 2); + assert_eq!([out[0].tag, out[1].tag], [1, 2]); + assert_eq!(queue.take(0, &mut out), 1); + assert_eq!(out[0].tag, 3); + release(rt); + } + + #[test] + fn null_runtime_has_no_events() { + let mut out: [TpbEvent; 1] = empty_events(); + assert_eq!( + unsafe { tpb_next_events(std::ptr::null_mut(), 0, out.as_mut_ptr(), 1) }, + 0 + ); + } + + #[test] + fn null_event_buffer_takes_no_events() { + let rt = runtime(); + unsafe { &*rt }.queue.push(1, 0, STATUS_OK, Vec::new()); + + for max in [0, 4] { + assert_eq!( + unsafe { tpb_next_events(rt, 0, std::ptr::null_mut(), max) }, + 0 + ); + } + assert_eq!(events(rt, 1), vec![(1, 0, STATUS_OK, vec![])]); + release(rt); + } + + #[test] + fn queue_works_after_a_panic_while_its_lock_was_held() { + let rt = runtime(); + let queue = unsafe { &*rt }.queue.clone(); + let poisoner = queue.clone(); + let poisoned = std::thread::spawn(move || { + let _held = poisoner.events.lock(); + panic!("poison") + }) + .join(); + assert!(poisoned.is_err()); + + queue.push(1, 0, STATUS_OK, Vec::new()); + + assert_eq!(events(rt, 1), vec![(1, 0, STATUS_OK, vec![])]); + release(rt); + } + + #[test] + fn poll_error_is_an_error_event() -> Checked { + let rt = runtime(); + let queue = &unsafe { &*rt }.queue; + let failed: Result = Err(PollError::TonicError( + tonic::Status::unavailable("server down"), + )); + + queue.push_poll(5, KIND_ACTIVITY_TASK, failed); + + let [(tag, kind, status, message)] = only(events(rt, 1))?; + assert_eq!((tag, kind, status), (5, KIND_ACTIVITY_TASK, STATUS_ERROR)); + assert!(String::from_utf8_lossy(&message).contains("server down")); + release(rt); + Ok(()) + } +} diff --git a/core/bridge/src/replay.rs b/core/bridge/src/replay.rs new file mode 100644 index 000000000..2455f0b3e --- /dev/null +++ b/core/bridge/src/replay.rs @@ -0,0 +1,130 @@ +use crate::config::{WorkerJson, parse}; +use crate::ffi::{bytes, construct, required}; +use crate::runtime::TpbRuntime; +use crate::worker::TpbWorker; +use prost::Message; +use temporalio_common::protos::temporal::api::history::v1::History; +use temporalio_sdk_core::replay::{HistoryForReplay, ReplayWorkerInput}; + +fn new_replayer( + rt: &TpbRuntime, + config: &[u8], + history: &[u8], + workflow_id: &[u8], +) -> Result { + let config: WorkerJson = parse(config, "worker config")?; + let history = History::decode(history).map_err(|e| format!("Invalid history: {e}"))?; + let workflow_id = std::str::from_utf8(workflow_id) + .map_err(|e| format!("Invalid workflow ID: {e}"))? + .to_owned(); + let input = ReplayWorkerInput::new( + config.worker_config()?, + futures_util::stream::iter([HistoryForReplay::new(history, workflow_id)]), + ); + let worker = { + let _guard = rt.queue.handle.enter(); + temporalio_sdk_core::init_replay_worker(input) + .map_err(|e| format!("Replay worker start failed: {e}"))? + }; + Ok(TpbWorker::new(worker, rt.queue.clone())) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_replayer_new( + rt: *mut TpbRuntime, + config: *const libc::c_char, + config_len: usize, + history: *const libc::c_char, + history_len: usize, + workflow_id: *const libc::c_char, + workflow_id_len: usize, + err: *mut *mut u8, + err_len: *mut usize, +) -> *mut TpbWorker { + construct(err, err_len, || { + new_replayer( + required(rt, "runtime")?, + bytes(config, config_len), + bytes(history, history_len), + bytes(workflow_id, workflow_id_len), + ) + }) +} + +#[cfg(test)] +mod tests { + use crate::ffi::{ + CALL_OK, KIND_SHUTDOWN_FINALIZED, KIND_WORKFLOW_ACTIVATION, STATUS_ERROR, STATUS_OK, + STATUS_SHUTDOWN, release, + }; + use crate::testing::{ + Checked, events, history, new_replayer, pending_events, replay_config, replayer, runtime, + }; + use crate::worker::{ + tpb_poll_workflow_activation, tpb_worker_finalize_shutdown, tpb_worker_free, + tpb_worker_initiate_shutdown, + }; + + #[test] + fn finalize_shuts_down_a_replayer_with_a_poll_in_flight() -> Checked { + let rt = runtime(); + let w = replayer(rt, false)?; + unsafe { tpb_poll_workflow_activation(w, 1) }; + assert_eq!(pending_events(rt), 0); + unsafe { + tpb_worker_finalize_shutdown(w, 2); + assert_eq!(tpb_worker_initiate_shutdown(w), CALL_OK); + } + assert_eq!( + events(rt, 2), + vec![ + (1, KIND_WORKFLOW_ACTIVATION, STATUS_SHUTDOWN, vec![]), + (2, KIND_SHUTDOWN_FINALIZED, STATUS_OK, vec![]), + ] + ); + unsafe { tpb_worker_finalize_shutdown(w, 3) }; + assert_eq!( + events(rt, 1), + vec![( + 3, + KIND_SHUTDOWN_FINALIZED, + STATUS_ERROR, + b"Worker is already finalized".to_vec() + )] + ); + unsafe { + tpb_worker_free(w); + release(rt); + } + Ok(()) + } + + #[test] + fn invalid_config_history_and_workflow_id_return_an_error() { + let rt = runtime(); + let error = |config: &str, history: &[u8], workflow_id: &[u8]| { + new_replayer(rt, config, history, workflow_id) + .err() + .unwrap_or_default() + }; + + assert!(error("{}", &history(false), b"wf").starts_with("Invalid worker config JSON: ")); + assert!(error(&replay_config(), b"\xff", b"wf").starts_with("Invalid history: ")); + assert!( + error(&replay_config(), &history(false), b"\xff").starts_with("Invalid workflow ID: ") + ); + release(rt); + } + + #[test] + fn null_runtime_is_an_error() { + let error = new_replayer( + std::ptr::null_mut(), + &replay_config(), + &history(false), + b"wf", + ); + + assert_eq!(error, Err("The runtime pointer is null".to_owned())); + } +} diff --git a/core/bridge/src/runtime.rs b/core/bridge/src/runtime.rs new file mode 100644 index 000000000..cc5c8ac25 --- /dev/null +++ b/core/bridge/src/runtime.rs @@ -0,0 +1,485 @@ +use crate::config::{RuntimeJson, parse}; +use crate::ffi::{bytes, construct}; +use crate::queue::Queue; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; +use temporalio_client::Connection; +use temporalio_common::telemetry::{ + CoreLog, CoreLogConsumer, Logger, PrometheusExporterOptions, TelemetryOptions, + build_otlp_metric_exporter, metrics::CoreMeter, start_prometheus_metric_exporter, +}; +use temporalio_sdk_core::{CoreRuntime, RuntimeOptions, TokioRuntimeBuilder}; +use tokio::sync::Mutex; + +const PROMETHEUS_PORT_ATTEMPTS: u16 = 64; + +pub struct TpbRuntime { + pub core: CoreRuntime, + pub queue: Arc, + pub connections: Mutex>, +} + +struct QueueLog(Arc>>); + +impl std::fmt::Debug for QueueLog { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("QueueLog") + } +} + +impl CoreLogConsumer for QueueLog { + fn on_log(&self, log: CoreLog) { + if let Some(queue) = self.0.get() { + queue.push_log(log.level.as_str(), &log.target, &log.message, &log.fields); + } + } +} + +fn prometheus_meter( + address: &str, + global_tags: Option>, + queue: &Queue, +) -> Result, String> { + let base: SocketAddr = address + .parse() + .map_err(|e| format!("Invalid Prometheus address {address}: {e}"))?; + let mut last_error = String::new(); + for offset in 0..PROMETHEUS_PORT_ATTEMPTS { + let Some(port) = base.port().checked_add(offset) else { + break; + }; + let mut socket_addr = base; + socket_addr.set_port(port); + match start_prometheus_metric_exporter( + PrometheusExporterOptions::builder() + .socket_addr(socket_addr) + .maybe_global_tags(global_tags.clone()) + .build(), + ) { + Ok(server) => { + let message = format!("Prometheus metrics on http://{socket_addr}/metrics"); + queue.push_log("INFO", env!("CARGO_CRATE_NAME"), &message, &HashMap::new()); + return Ok(server.meter); + } + Err(e) + if e.downcast_ref::() + .is_some_and(|e| e.kind() == std::io::ErrorKind::AddrInUse) => + { + last_error = e.to_string() + } + Err(e) => { + return Err(format!( + "Unable to start the Prometheus exporter on {socket_addr}: {e}" + )); + } + } + } + Err(format!( + "No free port for the Prometheus exporter from {base}: {last_error}" + )) +} + +fn new_runtime(config: &[u8]) -> Result { + let config: RuntimeJson = parse(config, "runtime config")?; + if config.prometheus.is_some() && config.otel.is_some() { + return Err("Prometheus and OpenTelemetry metrics cannot be used together".into()); + } + let log_queue = Arc::new(OnceLock::new()); + let telemetry = TelemetryOptions::builder() + .logging(Logger::Push { + filter: config.log, + consumer: Arc::new(QueueLog(log_queue.clone())), + }) + .maybe_metric_prefix(config.metric_prefix) + .build(); + let options = RuntimeOptions::builder() + .telemetry_options(telemetry) + .heartbeat_interval( + config + .worker_heartbeat_interval_ms + .map(Duration::from_millis), + ) + .build()?; + let mut tokio = TokioRuntimeBuilder::default(); + tokio.inner.worker_threads(config.threads.get()); + let mut core = CoreRuntime::new(options, tokio).map_err(|e| e.to_string())?; + let queue = Arc::new(Queue::new(core.tokio_handle())); + let _ = log_queue.set(queue.clone()); + let meter = { + let _guard = core.tokio_handle().enter(); + match (config.prometheus, config.otel) { + (Some(address), _) => Some(prometheus_meter(&address, config.global_tags, &queue)?), + (None, Some(otel)) => Some(Arc::new( + build_otlp_metric_exporter(otel.options(config.global_tags)?) + .map_err(|e| format!("Unable to start the OpenTelemetry exporter: {e}"))?, + ) as Arc), + (None, None) => None, + } + }; + if let Some(meter) = meter { + core.telemetry_mut().attach_late_init_metrics(meter); + } + Ok(TpbRuntime { + core, + queue, + connections: Mutex::new(HashMap::new()), + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_runtime_new( + config: *const libc::c_char, + config_len: usize, + err: *mut *mut u8, + err_len: *mut usize, +) -> *mut TpbRuntime { + construct(err, err_len, || new_runtime(bytes(config, config_len))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ffi::tpb_bytes_free; + use crate::ffi::{KIND_ACTIVITY_TASK, KIND_LOG, STATUS_OK}; + use crate::queue::MAX_PENDING_LOGS; + use crate::testing::{ + Checked, Event, drain_events, events, grpc_server, only, start_worker, worker_json, + }; + use crate::worker::tpb_worker_free; + use serde_json::Value; + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::{TcpListener, TcpStream}; + + fn scrape(port: u16) -> std::io::Result { + let mut stream = TcpStream::connect(("127.0.0.1", port))?; + stream + .write_all(b"GET /metrics HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response) + } + + fn log_entry(runtime: &TpbRuntime) -> Result> { + let rt = std::ptr::from_ref(runtime).cast_mut(); + let [(tag, kind, status, data)] = only(events(rt, 1))?; + assert_eq!((tag, kind, status), (0, KIND_LOG, STATUS_OK)); + Ok(serde_json::from_slice(&data)?) + } + + fn exporter_port(entry: &Value) -> Result> { + Ok(entry["message"] + .as_str() + .and_then(|message| message.strip_prefix("Prometheus metrics on http://127.0.0.1:")) + .and_then(|rest| rest.strip_suffix("/metrics")) + .ok_or("no exporter address in the log")? + .parse()?) + } + + fn otlp_request(listener: &TcpListener) -> std::io::Result<(Vec, Vec)> { + let (stream, _) = listener.accept()?; + let mut reader = BufReader::new(stream); + let head: Vec = reader + .by_ref() + .lines() + .map_while(Result::ok) + .take_while(|line| !line.is_empty()) + .collect(); + let length = head + .iter() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ")? + .parse() + .ok() + }) + .unwrap_or(0); + let mut body = vec![0; length]; + reader.read_exact(&mut body)?; + reader + .into_inner() + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")?; + Ok((head, body)) + } + + fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) + } + + #[test] + fn otlp_http_exporter_sends_prefixed_and_tagged_worker_metrics() -> Checked { + let collector = TcpListener::bind("127.0.0.1:0")?; + let port = collector.local_addr()?.port(); + let config = format!( + r#"{{"threads":1,"log":"off","metric_prefix":"php_","global_tags":{{"deployment":"otlp-test"}}, + "otel":{{"url":"http://127.0.0.1:{port}/v1/metrics","headers":{{"x-api-key":"secret"}}, + "metric_periodicity_ms":100,"protocol":"http","use_seconds_for_durations":true}}}}"# + ); + let runtime = new_runtime(config.as_bytes())?; + let rt = std::ptr::from_ref(&runtime).cast_mut(); + let worker = start_worker(rt, &worker_json(&grpc_server()?))?; + + let received = (0..50).map(|_| otlp_request(&collector)).find(|request| { + request + .as_ref() + .map_or(true, |(_, body)| contains(body, b"php_request")) + }); + unsafe { tpb_worker_free(worker) }; + + let (head, body) = received.ok_or("no php_request metric was exported")??; + assert_eq!(head[0], "POST /v1/metrics HTTP/1.1"); + assert!( + head.iter() + .any(|line| line.eq_ignore_ascii_case("x-api-key: secret")), + "{head:?}" + ); + assert!(contains(&body, b"otlp-test")); + Ok(()) + } + + #[test] + fn log_consumer_debug_output_has_no_queue_contents() { + assert_eq!( + format!("{:?}", QueueLog(Arc::new(OnceLock::new()))), + "QueueLog" + ); + } + + #[test] + fn prometheus_exporter_moves_to_the_next_free_port_and_logs_it() -> Checked { + let taken = TcpListener::bind("127.0.0.1:0")?; + let port = taken.local_addr()?.port(); + let config = format!(r#"{{"threads":1,"log":"off","prometheus":"127.0.0.1:{port}"}}"#); + + let runtime = new_runtime(config.as_bytes())?; + + let entry = log_entry(&runtime)?; + assert_eq!(entry["level"], "INFO"); + assert_eq!(entry["target"], "temporal_php_bridge"); + assert_eq!(entry["fields"], serde_json::json!({})); + let exporter_port = exporter_port(&entry)?; + assert!(exporter_port > port); + assert!(scrape(exporter_port)?.starts_with("HTTP/1.1 200")); + Ok(()) + } + + #[test] + fn worker_connection_records_client_request_metrics() -> Checked { + let taken = TcpListener::bind("127.0.0.1:0")?; + let port = taken.local_addr()?.port(); + let config = format!(r#"{{"threads":1,"log":"off","prometheus":"127.0.0.1:{port}"}}"#); + let runtime = new_runtime(config.as_bytes())?; + let exporter_port = exporter_port(&log_entry(&runtime)?)?; + let rt = std::ptr::from_ref(&runtime).cast_mut(); + + let worker = start_worker(rt, &worker_json(&grpc_server()?))?; + + let metrics = scrape(exporter_port)?; + unsafe { tpb_worker_free(worker) }; + assert!( + metrics.contains("temporal_request{"), + "no client request metric in {metrics}" + ); + Ok(()) + } + + #[test] + fn prometheus_metrics_carry_the_metric_prefix_and_the_global_tags() -> Checked { + let taken = TcpListener::bind("127.0.0.1:0")?; + let port = taken.local_addr()?.port(); + let config = format!( + r#"{{"threads":1,"log":"off","prometheus":"127.0.0.1:{port}","metric_prefix":"php_","global_tags":{{"deployment":"prom-test"}}}}"# + ); + let runtime = new_runtime(config.as_bytes())?; + let exporter_port = exporter_port(&log_entry(&runtime)?)?; + let rt = std::ptr::from_ref(&runtime).cast_mut(); + + let worker = start_worker(rt, &worker_json(&grpc_server()?))?; + + let metrics = scrape(exporter_port)?; + unsafe { tpb_worker_free(worker) }; + assert!( + metrics.lines().any(|line| line.starts_with("php_request{") + && line.contains(r#"deployment="prom-test""#)), + "no prefixed and tagged request metric in {metrics}" + ); + Ok(()) + } + + #[test] + fn prometheus_and_opentelemetry_metrics_are_alternatives() { + let config = br#"{"threads":1,"log":"off","prometheus":"127.0.0.1:0","otel":{"url":"http://127.0.0.1:4317","headers":null,"metric_periodicity_ms":1000,"protocol":"grpc","use_seconds_for_durations":false}}"#; + + assert_eq!( + new_runtime(config).err().as_deref(), + Some("Prometheus and OpenTelemetry metrics cannot be used together") + ); + } + + #[test] + fn invalid_opentelemetry_url_is_an_error() { + let config = br#"{"threads":1,"log":"off","otel":{"url":"not a url","headers":null,"metric_periodicity_ms":1000,"protocol":"http","use_seconds_for_durations":false}}"#; + + assert_eq!( + new_runtime(config).err().as_deref(), + Some("Invalid OpenTelemetry URL not a url: relative URL without a base") + ); + } + + #[test] + fn opentelemetry_grpc_exporter_rejects_an_invalid_header() { + let config = br#"{"threads":1,"log":"off","otel":{"url":"http://127.0.0.1:4317","headers":{"bad header":"x"},"metric_periodicity_ms":1000,"protocol":"grpc","use_seconds_for_durations":false}}"#; + + let error = new_runtime(config).err(); + + assert!( + error + .as_deref() + .is_some_and(|e| e.starts_with("Unable to start the OpenTelemetry exporter: ")), + "{error:?}" + ); + } + + #[test] + fn prometheus_exporter_stops_at_the_last_port() { + let _taken = TcpListener::bind("127.0.0.1:65535"); + let config = br#"{"threads":1,"log":"off","prometheus":"127.0.0.1:65535"}"#; + + assert!(new_runtime(config).is_err_and(|e| { + e.starts_with("No free port for the Prometheus exporter from 127.0.0.1:65535: ") + })); + } + + #[test] + fn prometheus_exporter_stays_on_the_port_when_the_address_is_not_local() { + let config = br#"{"threads":1,"log":"off","prometheus":"192.0.2.1:9464"}"#; + + let error = new_runtime(config).err(); + + assert!( + error + .as_deref() + .is_some_and(|e| e + .starts_with("Unable to start the Prometheus exporter on 192.0.2.1:9464: ")), + "{error:?}" + ); + } + + #[test] + fn zero_threads_is_a_config_error() { + let error = new_runtime(br#"{"threads":0,"log":"off"}"#).err(); + + assert!( + error.as_deref().is_some_and(|e| e.starts_with( + "Invalid runtime config JSON: invalid value: integer `0`, expected a nonzero usize" + )), + "{error:?}" + ); + } + + #[test] + fn core_logs_are_queued_as_log_events() -> Checked { + let runtime = new_runtime(br#"{"threads":1,"log":"warn"}"#)?; + + runtime.core.tokio_handle().block_on(async { + tracing::warn!(target: "temporalio_sdk_core", answer = 42, "core warning"); + }); + + let entry = log_entry(&runtime)?; + assert_eq!(entry["level"], "WARN"); + assert_eq!(entry["target"], "temporalio_sdk_core"); + assert_eq!(entry["message"], "core warning"); + assert_eq!(entry["fields"]["answer"], 42); + Ok(()) + } + + fn messages(events: &[Event]) -> Result, serde_json::Error> { + events + .iter() + .filter(|(_, kind, _, _)| *kind == KIND_LOG) + .map(|(_, _, _, data)| { + serde_json::from_slice::(data).map(|e| e["message"].clone()) + }) + .collect() + } + + #[test] + fn pending_logs_stop_at_the_cap_and_the_loss_is_logged_when_logs_fit_again() -> Checked { + let runtime = new_runtime(br#"{"threads":1,"log":"off"}"#)?; + let rt = std::ptr::from_ref(&runtime).cast_mut(); + let consumer = QueueLog(Arc::new(OnceLock::from(runtime.queue.clone()))); + let log = |message: String| CoreLog { + target: "temporalio_sdk_core".into(), + message, + timestamp: std::time::SystemTime::now(), + level: tracing::Level::DEBUG, + fields: HashMap::new(), + span_contexts: Vec::new(), + }; + + for i in 0..MAX_PENDING_LOGS + 3 { + consumer.on_log(log(format!("flood {i}"))); + } + runtime + .queue + .push(1, KIND_ACTIVITY_TASK, STATUS_OK, Vec::new()); + + let flood = drain_events(rt); + assert_eq!(flood.len(), MAX_PENDING_LOGS + 1); + assert_eq!( + flood.last(), + Some(&(1, KIND_ACTIVITY_TASK, STATUS_OK, vec![])) + ); + assert_eq!( + messages(&flood)?.last(), + Some(&Value::from(format!("flood {}", MAX_PENDING_LOGS - 1))) + ); + + consumer.on_log(log("after".into())); + + let after = drain_events(rt); + assert_eq!(after.len(), 2); + let notice: Value = serde_json::from_slice(&after[0].3)?; + assert_eq!(notice["level"], "WARN"); + assert_eq!(notice["target"], "temporal_php_bridge"); + assert_eq!( + notice["message"], + "3 log records were dropped: the event queue was full" + ); + assert_eq!(messages(&after[1..])?, vec![Value::from("after")]); + Ok(()) + } + + #[test] + fn worker_heartbeat_interval_outside_one_to_sixty_seconds_is_an_error() -> Checked { + let config = br#"{"threads":1,"log":"off","worker_heartbeat_interval_ms":500}"#; + + assert_eq!( + new_runtime(config).err().as_deref(), + Some("heartbeat_interval (500ms) must be between 1s and 60s") + ); + new_runtime(br#"{"threads":1,"log":"off","worker_heartbeat_interval_ms":1000}"#)?; + Ok(()) + } + + #[test] + fn invalid_prometheus_address_is_an_error() { + let config = br#"{"threads":1,"log":"off","prometheus":"not an address"}"#; + + assert!(new_runtime(config).is_err_and(|e| e.contains("Invalid Prometheus address"))); + } + + #[test] + fn invalid_runtime_config_is_returned_through_the_error_pointers() { + let (mut err, mut err_len) = (std::ptr::null_mut(), 0); + let config = b"{"; + + let rt = unsafe { tpb_runtime_new(config.as_ptr().cast(), 1, &mut err, &mut err_len) }; + + assert!(rt.is_null()); + let message = bytes(err.cast(), err_len); + assert!(message.starts_with(b"Invalid runtime config JSON: ")); + unsafe { tpb_bytes_free(err, err_len) }; + } +} diff --git a/core/bridge/src/testing.rs b/core/bridge/src/testing.rs new file mode 100644 index 000000000..3ecddfb58 --- /dev/null +++ b/core/bridge/src/testing.rs @@ -0,0 +1,311 @@ +use crate::ffi::{TpbEvent, bytes, tpb_bytes_free}; +use crate::queue::tpb_next_events; +use crate::replay::tpb_replayer_new; +use crate::runtime::{TpbRuntime, tpb_runtime_new}; +use crate::worker::{TpbWorker, tpb_worker_new}; +use prost::{Message, bytes::Bytes}; +use std::sync::{Arc, Mutex, PoisonError}; +use temporalio_common::protos::temporal::api::{ + common::v1::WorkflowType, + enums::v1::EventType, + history::v1::{ + History, HistoryEvent, WorkflowExecutionStartedEventAttributes, history_event::Attributes, + }, + taskqueue::v1::TaskQueue, +}; +use tonic::codegen::http; + +pub type Event = (u64, i32, i32, Vec); + +pub type Checked = Result<(), Box>; + +pub const GRPC_OK: &str = "0"; + +pub fn only(events: Vec) -> Result<[Event; N], String> { + events + .try_into() + .map_err(|events| format!("expected {N} events, received {events:?}")) +} + +pub fn runtime() -> *mut TpbRuntime { + let config = br#"{"threads":1,"log":"off"}"#; + let rt = unsafe { + tpb_runtime_new( + config.as_ptr().cast(), + config.len(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert!(!rt.is_null()); + rt +} + +pub fn empty_events() -> [TpbEvent; N] { + std::array::from_fn(|_| TpbEvent { + tag: 0, + kind: 0, + status: 0, + data: std::ptr::null_mut(), + len: 0, + }) +} + +pub fn pending_events(rt: *mut TpbRuntime) -> usize { + let mut buf: [TpbEvent; 1] = empty_events(); + unsafe { tpb_next_events(rt, 300, buf.as_mut_ptr(), buf.len()) } +} + +fn take_events(rt: *mut TpbRuntime, timeout_ms: u32) -> Vec { + let mut buf: [TpbEvent; 8] = empty_events(); + let taken = unsafe { tpb_next_events(rt, timeout_ms, buf.as_mut_ptr(), buf.len()) }; + buf[..taken] + .iter() + .map(|e| { + let event = ( + e.tag, + e.kind, + e.status, + bytes(e.data.cast(), e.len).to_vec(), + ); + unsafe { tpb_bytes_free(e.data, e.len) }; + event + }) + .collect() +} + +pub fn events(rt: *mut TpbRuntime, count: usize) -> Vec { + let mut events = Vec::new(); + while events.len() < count { + let taken = take_events(rt, 5_000); + assert!( + !taken.is_empty(), + "no event in 5 seconds, received {events:?}" + ); + events.extend(taken); + } + events.sort(); + events +} + +pub fn drain_events(rt: *mut TpbRuntime) -> Vec { + let mut events = Vec::new(); + loop { + let taken = take_events(rt, 0); + if taken.is_empty() { + return events; + } + events.extend(taken); + } +} + +pub fn grpc_server() -> std::io::Result { + grpc_server_with(|_| GRPC_OK) +} + +pub fn grpc_server_with(status: fn(&str) -> &'static str) -> std::io::Result { + serve(status, Authorizations::default()) +} + +pub type Authorizations = Arc>>>; + +pub fn grpc_server_seeing_authorizations() -> std::io::Result<(String, Authorizations)> { + let authorizations = Authorizations::default(); + Ok((serve(|_| GRPC_OK, authorizations.clone())?, authorizations)) +} + +fn serve( + status: fn(&str) -> &'static str, + authorizations: Authorizations, +) -> std::io::Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + listener.set_nonblocking(true)?; + let address = format!("http://{}", listener.local_addr()?); + std::thread::spawn(move || -> std::io::Result<()> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener)?; + loop { + let (socket, _) = listener.accept().await?; + tokio::spawn(answer(socket, status, authorizations.clone())); + } + }) + }); + Ok(address) +} + +async fn answer( + socket: tokio::net::TcpStream, + status: fn(&str) -> &'static str, + authorizations: Authorizations, +) { + let Ok(mut connection) = h2::server::handshake(socket).await else { + return; + }; + while let Some(Ok((request, mut respond))) = connection.accept().await { + let grpc_status = status(request.uri().path()); + if request.uri().path().ends_with("/PollWorkflowTaskQueue") { + let authorization = request + .headers() + .get(http::header::AUTHORIZATION) + .map(|value| String::from_utf8_lossy(value.as_bytes()).into_owned()); + authorizations + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(authorization); + } + let mut response = http::Response::new(()); + response.headers_mut().insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/grpc"), + ); + let Ok(mut stream) = respond.send_response(response, false) else { + continue; + }; + let _ = stream.send_data(Bytes::from_static(&[0; 5]), false); + let _ = stream.send_trailers(http::HeaderMap::from_iter([( + http::HeaderName::from_static("grpc-status"), + http::HeaderValue::from_static(grpc_status), + )])); + } +} + +pub fn worker_json(target_url: &str) -> serde_json::Value { + serde_json::json!({ + "connection": { + "target_url": target_url, + "client_name": "temporal-php-2", + "client_version": "2.0.0", + "identity": "1@host", + "api_key": null, + "tls": null, + "connect_timeout_ms": 10000, + "grpc_compression": "gzip", + }, + "namespace": "default", + "task_queue": "q", + "workflows": true, + "local_activities": true, + "remote_activities": false, + "deployment": null, + "build_id": "", + "graceful_shutdown_period_ms": 1500, + "max_worker_activities_per_second": 2.5, + "max_task_queue_activities_per_second": null, + "max_cached_workflows": 10000, + "max_outstanding_workflow_tasks": 100, + "max_outstanding_activities": 1, + "max_outstanding_local_activities": 1, + "max_concurrent_workflow_task_polls": 8, + "max_concurrent_activity_task_polls": 1, + "nonsticky_to_sticky_poll_ratio": 0.5, + "sticky_queue_schedule_to_start_timeout_ms": 5000, + "nondeterminism_fails_workflow": false, + "max_heartbeat_throttle_interval_ms": null, + "poller_autoscaling": false, + }) +} + +pub fn replay_config() -> String { + let mut json = worker_json(""); + if let Some(fields) = json.as_object_mut() { + fields.remove("connection"); + } + json.to_string() +} + +pub fn history(with_workflow_task: bool) -> Vec { + let started = WorkflowExecutionStartedEventAttributes { + workflow_type: Some(WorkflowType { + name: "Replayed".into(), + }), + original_execution_run_id: "run".into(), + task_queue: Some(TaskQueue { + name: "q".into(), + ..Default::default() + }), + ..Default::default() + }; + let mut events = vec![( + EventType::WorkflowExecutionStarted, + Attributes::WorkflowExecutionStartedEventAttributes(started), + )]; + if with_workflow_task { + events.push(( + EventType::WorkflowTaskScheduled, + Attributes::WorkflowTaskScheduledEventAttributes(Default::default()), + )); + events.push(( + EventType::WorkflowTaskStarted, + Attributes::WorkflowTaskStartedEventAttributes(Default::default()), + )); + } + History { + events: (1..) + .zip(events) + .map(|(event_id, (event_type, attributes))| HistoryEvent { + event_id, + event_type: event_type as i32, + attributes: Some(attributes), + ..Default::default() + }) + .collect(), + } + .encode_to_vec() +} + +pub fn new_replayer( + rt: *mut TpbRuntime, + config: &str, + history: &[u8], + workflow_id: &[u8], +) -> Result<*mut TpbWorker, String> { + let (mut err, mut err_len) = (std::ptr::null_mut(), 0); + let w = unsafe { + tpb_replayer_new( + rt, + config.as_ptr().cast(), + config.len(), + history.as_ptr().cast(), + history.len(), + workflow_id.as_ptr().cast(), + workflow_id.len(), + &mut err, + &mut err_len, + ) + }; + created(w, err, err_len) +} + +pub fn start_worker( + rt: *mut TpbRuntime, + json: &serde_json::Value, +) -> Result<*mut TpbWorker, String> { + let config = json.to_string(); + let (mut err, mut err_len) = (std::ptr::null_mut(), 0); + let w = unsafe { + tpb_worker_new( + rt, + config.as_ptr().cast(), + config.len(), + &mut err, + &mut err_len, + ) + }; + created(w, err, err_len) +} + +fn created(object: *mut T, err: *mut u8, err_len: usize) -> Result<*mut T, String> { + if !object.is_null() { + return Ok(object); + } + let message = String::from_utf8_lossy(bytes(err.cast(), err_len)).into_owned(); + unsafe { tpb_bytes_free(err, err_len) }; + Err(message) +} + +pub fn replayer(rt: *mut TpbRuntime, with_workflow_task: bool) -> Result<*mut TpbWorker, String> { + new_replayer(rt, &replay_config(), &history(with_workflow_task), b"wf") +} diff --git a/core/bridge/src/worker.rs b/core/bridge/src/worker.rs new file mode 100644 index 000000000..f88314d30 --- /dev/null +++ b/core/bridge/src/worker.rs @@ -0,0 +1,679 @@ +use crate::config::WorkerJson; +use crate::ffi::{ + CALL_FAILED, CALL_OK, KIND_ACTIVITY_COMPLETED, KIND_ACTIVITY_TASK, KIND_SHUTDOWN_FINALIZED, + KIND_WORKFLOW_ACTIVATION, KIND_WORKFLOW_COMPLETED, STATUS_ERROR, STATUS_OK, bytes, call, + construct, release, required, +}; +use crate::queue::{Queue, error_status}; +use crate::runtime::TpbRuntime; +use prost::Message; +use std::{ + collections::hash_map::Entry, + fmt::Display, + future::Future, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; +use temporalio_client::Connection; +use temporalio_common::protos::coresdk::{ + ActivityHeartbeat, ActivityTaskCompletion, workflow_completion::WorkflowActivationCompletion, +}; +use temporalio_sdk_core::{PollError, Worker}; +use tokio::sync::{OwnedRwLockReadGuard, RwLock}; + +const FINALIZED: &str = "Worker is already finalized"; + +type Core = OwnedRwLockReadGuard, Worker>; + +pub struct TpbWorker { + worker: Arc>>, + shutdown_initiated: AtomicBool, + queue: Arc, +} + +impl TpbWorker { + pub fn new(worker: Worker, queue: Arc) -> Self { + Self { + worker: Arc::new(RwLock::new(Some(worker))), + shutdown_initiated: AtomicBool::new(false), + queue, + } + } + + fn core(&self, tag: u64, kind: i32) -> Option { + let core = self + .worker + .clone() + .try_read_owned() + .ok() + .and_then(|worker| OwnedRwLockReadGuard::try_map(worker, Option::as_ref).ok()); + if core.is_none() { + self.queue + .push(tag, kind, STATUS_ERROR, FINALIZED.as_bytes().to_vec()); + } + core + } + + fn call(&self, body: impl FnOnce(&Worker)) -> i32 { + let Ok(worker) = self.worker.try_read() else { + return CALL_FAILED; + }; + let Some(core) = worker.as_ref() else { + return CALL_FAILED; + }; + let _guard = self.queue.handle.enter(); + body(core); + CALL_OK + } + + fn set_api_key(&self, api_key: Option) -> i32 { + let Ok(worker) = self.worker.try_read() else { + return CALL_FAILED; + }; + let Some(connection) = worker.as_ref().and_then(Worker::get_client_connection) else { + return CALL_FAILED; + }; + connection.set_api_key(api_key); + CALL_OK + } + + fn initiate_shutdown(&self) -> i32 { + if self.shutdown_initiated.load(Ordering::Acquire) { + return CALL_OK; + } + let status = self.call(Worker::initiate_shutdown); + if status == CALL_OK { + self.shutdown_initiated.store(true, Ordering::Release); + } + status + } + + fn finalize(&self) -> impl Future + Send + 'static { + self.initiate_shutdown(); + let worker = self.worker.clone(); + async move { + let core = worker.write().await.take(); + let Some(core) = core else { + return false; + }; + core.finalize_shutdown().await; + true + } + } + + fn poll(&self, tag: u64, kind: i32, poll: impl FnOnce(Core) -> F) + where + T: Message, + F: Future> + Send + 'static, + { + let Some(core) = self.core(tag, kind) else { + return; + }; + let queue = self.queue.clone(); + let result = poll(core); + self.queue.spawn(tag, kind, error_status, async move { + queue.push_poll(tag, kind, result.await); + }); + } + + fn complete( + &self, + tag: u64, + kind: i32, + data: &[u8], + complete: impl FnOnce(Core, C) -> F, + ) where + C: Message + Default, + E: Display, + F: Future> + Send + 'static, + { + let completion = match C::decode(data) { + Ok(completion) => completion, + Err(e) => { + return self + .queue + .push_error(tag, kind, Err(format!("Decode failure: {e}"))); + } + }; + let Some(core) = self.core(tag, kind) else { + return; + }; + let queue = self.queue.clone(); + let result = complete(core, completion); + self.queue.spawn(tag, kind, error_status, async move { + queue.push_error(tag, kind, result.await.map_err(|e| e.to_string())); + }); + } +} + +impl Drop for TpbWorker { + fn drop(&mut self) { + self.queue.handle.spawn(self.finalize()); + } +} + +fn new_worker(rt: &TpbRuntime, config: &[u8]) -> Result { + let (connection_key, connection, config) = WorkerJson::parse_with_connection(config)?; + let worker_config = config.worker_config()?; + let options = connection.options(rt.core.telemetry().get_temporal_metric_meter())?; + let worker = rt.queue.handle.block_on(async { + let connection = match rt.connections.lock().await.entry(connection_key) { + Entry::Occupied(cached) => cached.get().clone(), + Entry::Vacant(entry) => entry + .insert( + Connection::connect(options) + .await + .map_err(|e| format!("Connection failed: {e}"))?, + ) + .clone(), + }; + let worker = temporalio_sdk_core::init_worker(&rt.core, worker_config, connection) + .map_err(|e| format!("Worker start failed: {e}"))?; + if let Err(e) = worker.validate().await { + worker.initiate_shutdown(); + worker.finalize_shutdown().await; + return Err(format!("Worker validation failed: {e}")); + } + Ok::<_, String>(worker) + })?; + Ok(TpbWorker::new(worker, rt.queue.clone())) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_worker_new( + rt: *mut TpbRuntime, + config: *const libc::c_char, + config_len: usize, + err: *mut *mut u8, + err_len: *mut usize, +) -> *mut TpbWorker { + construct(err, err_len, || { + new_worker(required(rt, "runtime")?, bytes(config, config_len)) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_poll_workflow_activation(w: *mut TpbWorker, tag: u64) { + call(w, (), |w| { + w.poll(tag, KIND_WORKFLOW_ACTIVATION, |core| async move { + core.poll_workflow_activation().await + }) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_poll_activity_task(w: *mut TpbWorker, tag: u64) { + call(w, (), |w| { + w.poll(tag, KIND_ACTIVITY_TASK, |core| async move { + core.poll_activity_task().await + }) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_complete_workflow_activation( + w: *mut TpbWorker, + tag: u64, + data: *const libc::c_char, + len: usize, +) { + call(w, (), |w| { + w.complete( + tag, + KIND_WORKFLOW_COMPLETED, + bytes(data, len), + |core, completion: WorkflowActivationCompletion| async move { + core.complete_workflow_activation(completion).await + }, + ) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_complete_activity_task( + w: *mut TpbWorker, + tag: u64, + data: *const libc::c_char, + len: usize, +) { + call(w, (), |w| { + w.complete( + tag, + KIND_ACTIVITY_COMPLETED, + bytes(data, len), + |core, completion: ActivityTaskCompletion| async move { + core.complete_activity_task(completion).await + }, + ) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_record_activity_heartbeat( + w: *mut TpbWorker, + data: *const libc::c_char, + len: usize, +) -> i32 { + call(w, CALL_FAILED, |w| { + let Ok(heartbeat) = ActivityHeartbeat::decode(bytes(data, len)) else { + return CALL_FAILED; + }; + w.call(|core| core.record_activity_heartbeat(heartbeat)) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_worker_set_api_key( + w: *mut TpbWorker, + api_key: *const libc::c_char, + len: usize, +) -> i32 { + call(w, CALL_FAILED, |w| { + let api_key = bytes(api_key, len); + w.set_api_key((!api_key.is_empty()).then(|| String::from_utf8_lossy(api_key).into_owned())) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_worker_initiate_shutdown(w: *mut TpbWorker) -> i32 { + call(w, CALL_FAILED, TpbWorker::initiate_shutdown) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_worker_finalize_shutdown(w: *mut TpbWorker, tag: u64) { + call(w, (), |w| { + let finalize = w.finalize(); + let queue = w.queue.clone(); + w.queue + .spawn(tag, KIND_SHUTDOWN_FINALIZED, error_status, async move { + if !finalize.await { + return queue.push_error(tag, KIND_SHUTDOWN_FINALIZED, Err(FINALIZED.into())); + } + queue.push(tag, KIND_SHUTDOWN_FINALIZED, STATUS_OK, Vec::new()); + }); + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tpb_worker_free(w: *mut TpbWorker) { + release(w) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ffi::STATUS_SHUTDOWN; + use crate::testing::{ + Authorizations, Checked, GRPC_OK, events, grpc_server, grpc_server_seeing_authorizations, + grpc_server_with, only, pending_events, replayer, runtime, start_worker, worker_json, + }; + use std::{ + sync::PoisonError, + time::{Duration, Instant}, + }; + use temporalio_common::protos::coresdk::{ + activity_result::ActivityExecutionResult, + workflow_activation::WorkflowActivation, + workflow_completion::{Success, workflow_activation_completion::Status}, + }; + + const GRPC_NOT_FOUND: &str = "5"; + + fn text(data: &[u8]) -> String { + String::from_utf8_lossy(data).into_owned() + } + + fn last_poll_sends(authorizations: &Authorizations, expected: Option<&str>) -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + let last = authorizations + .lock() + .unwrap_or_else(PoisonError::into_inner) + .last() + .cloned(); + if last == Some(expected.map(str::to_owned)) { + return true; + } + std::thread::sleep(Duration::from_millis(10)); + } + false + } + + fn set_api_key(w: *mut TpbWorker, api_key: &str) -> i32 { + unsafe { tpb_worker_set_api_key(w, api_key.as_ptr().cast(), api_key.len()) } + } + + #[test] + fn polls_send_the_api_key_set_at_run_time() -> Checked { + let rt = runtime(); + let (server, authorizations) = grpc_server_seeing_authorizations()?; + let mut json = worker_json(&server); + json["connection"]["api_key"] = "old".into(); + let w = start_worker(rt, &json)?; + unsafe { tpb_poll_workflow_activation(w, 1) }; + assert!(last_poll_sends(&authorizations, Some("Bearer old"))); + + assert_eq!(set_api_key(w, "new"), CALL_OK); + assert!(last_poll_sends(&authorizations, Some("Bearer new"))); + + assert_eq!(set_api_key(w, ""), CALL_OK); + assert!(last_poll_sends(&authorizations, None)); + + unsafe { tpb_worker_finalize_shutdown(w, 2) }; + assert_eq!( + events(rt, 2), + vec![ + (1, KIND_WORKFLOW_ACTIVATION, STATUS_SHUTDOWN, vec![]), + (2, KIND_SHUTDOWN_FINALIZED, STATUS_OK, vec![]), + ] + ); + unsafe { + tpb_worker_free(w); + release(rt); + } + Ok(()) + } + + #[test] + fn replayer_has_no_connection_for_an_api_key() -> Checked { + let rt = runtime(); + let w = replayer(rt, false)?; + + assert_eq!(set_api_key(w, "key"), CALL_FAILED); + unsafe { + tpb_worker_free(w); + release(rt); + } + Ok(()) + } + + #[test] + fn failed_validation_does_not_keep_the_task_queue_registered() -> Checked { + let rt = runtime(); + let server = grpc_server_with(|path| match path.ends_with("/DescribeNamespace") { + true => GRPC_NOT_FOUND, + false => GRPC_OK, + })?; + + for _ in 0..2 { + let error = start_worker(rt, &worker_json(&server)).err(); + assert!( + error + .as_deref() + .is_some_and(|e| e.starts_with("Worker validation failed: ")), + "{error:?}" + ); + } + release(rt); + Ok(()) + } + + #[test] + fn null_runtime_is_an_error() { + let error = start_worker(std::ptr::null_mut(), &worker_json("http://127.0.0.1:1")); + + assert_eq!(error, Err("The runtime pointer is null".to_owned())); + } + + #[test] + fn calls_on_a_null_worker_fail() { + let null = std::ptr::null_mut(); + unsafe { + tpb_poll_workflow_activation(null, 1); + tpb_poll_activity_task(null, 2); + tpb_complete_workflow_activation(null, 3, std::ptr::null(), 0); + tpb_complete_activity_task(null, 4, std::ptr::null(), 0); + assert_eq!( + tpb_record_activity_heartbeat(null, std::ptr::null(), 0), + CALL_FAILED + ); + assert_eq!(tpb_worker_initiate_shutdown(null), CALL_FAILED); + assert_eq!(set_api_key(null, "key"), CALL_FAILED); + tpb_worker_finalize_shutdown(null, 5); + tpb_worker_free(null); + } + } + + #[test] + fn freeing_an_unfinalized_worker_releases_its_task_queue() -> Checked { + let rt = runtime(); + let server = grpc_server()?; + unsafe { tpb_worker_free(start_worker(rt, &worker_json(&server))?) }; + + let w = start_worker(rt, &worker_json(&server))?; + + unsafe { tpb_worker_finalize_shutdown(w, 1) }; + assert_eq!( + events(rt, 1), + vec![(1, KIND_SHUTDOWN_FINALIZED, STATUS_OK, vec![])] + ); + unsafe { + tpb_worker_free(w); + release(rt); + } + Ok(()) + } + + #[test] + fn workers_with_the_same_connection_share_one_connection() -> Checked { + let rt = runtime(); + let server = grpc_server()?; + let first = start_worker(rt, &worker_json(&server))?; + let mut another_queue = worker_json(&server); + another_queue["task_queue"] = "another".into(); + let second = start_worker(rt, &another_queue)?; + + assert_eq!( + unsafe { &*rt }.connections.try_lock().map(|c| c.len()).ok(), + Some(1) + ); + unsafe { + tpb_worker_finalize_shutdown(first, 1); + tpb_worker_finalize_shutdown(second, 2); + } + assert_eq!( + events(rt, 2), + vec![ + (1, KIND_SHUTDOWN_FINALIZED, STATUS_OK, vec![]), + (2, KIND_SHUTDOWN_FINALIZED, STATUS_OK, vec![]), + ] + ); + unsafe { + tpb_worker_free(first); + tpb_worker_free(second); + release(rt); + } + Ok(()) + } + + #[test] + fn concurrent_workers_for_one_task_queue_share_the_connection() -> Checked { + let rt = runtime(); + let server = grpc_server()?; + let runtime_address = rt as usize; + let start = std::sync::Barrier::new(2); + + let created: Vec> = std::thread::scope(|scope| { + let threads: Vec<_> = (0..2) + .map(|_| { + scope.spawn(|| { + start.wait(); + start_worker(runtime_address as *mut TpbRuntime, &worker_json(&server)) + .map(|w| w as usize) + }) + }) + .collect(); + threads + .into_iter() + .map(|thread| thread.join().unwrap_or(Err("panicked".into()))) + .collect() + }); + + let errors: Vec<&String> = created.iter().filter_map(|w| w.as_ref().err()).collect(); + assert!( + errors.len() == 1 && errors[0].contains("Registration of multiple workers"), + "{created:?}" + ); + for w in created.into_iter().flatten() { + unsafe { tpb_worker_free(w as *mut TpbWorker) }; + } + release(rt); + Ok(()) + } + + #[test] + fn replayer_polls_and_completes_workflow_activations() -> Checked { + let rt = runtime(); + let w = replayer(rt, true)?; + + unsafe { tpb_poll_workflow_activation(w, 1) }; + let [(tag, kind, status, activation)] = only(events(rt, 1))?; + assert_eq!( + (tag, kind, status), + (1, KIND_WORKFLOW_ACTIVATION, STATUS_OK) + ); + let run_id = WorkflowActivation::decode(&activation[..])?.run_id; + assert_eq!(run_id, "run"); + + let completion = WorkflowActivationCompletion { + run_id, + status: Some(Status::Successful(Success::default())), + ..Default::default() + } + .encode_to_vec(); + let undecodable = b"\xff"; + unsafe { + tpb_complete_workflow_activation(w, 2, completion.as_ptr().cast(), completion.len()); + tpb_complete_workflow_activation(w, 3, undecodable.as_ptr().cast(), 1); + } + let [(tag, kind, status, error)] = only(events(rt, 1))?; + assert_eq!( + (tag, kind, status), + (3, KIND_WORKFLOW_COMPLETED, STATUS_ERROR) + ); + assert!(text(&error).starts_with("Decode failure: ")); + assert_eq!(pending_events(rt), 0); + unsafe { + tpb_worker_free(w); + release(rt); + } + Ok(()) + } + + #[test] + fn replayer_has_no_activities_but_accepts_heartbeats() -> Checked { + let rt = runtime(); + let w = replayer(rt, false)?; + let completion = ActivityTaskCompletion { + task_token: vec![1], + result: Some(ActivityExecutionResult::ok(Default::default())), + } + .encode_to_vec(); + let heartbeat = ActivityHeartbeat { + task_token: vec![1], + details: vec![], + } + .encode_to_vec(); + let undecodable = b"\xff"; + + unsafe { + tpb_poll_activity_task(w, 1); + tpb_complete_activity_task(w, 2, completion.as_ptr().cast(), completion.len()); + assert_eq!( + tpb_record_activity_heartbeat(w, heartbeat.as_ptr().cast(), heartbeat.len()), + CALL_OK + ); + assert_eq!( + tpb_record_activity_heartbeat(w, undecodable.as_ptr().cast(), 1), + CALL_FAILED + ); + } + + let [poll, (tag, kind, status, error)] = only(events(rt, 2))?; + assert_eq!(poll, (1, KIND_ACTIVITY_TASK, STATUS_SHUTDOWN, vec![])); + assert_eq!( + (tag, kind, status), + (2, KIND_ACTIVITY_COMPLETED, STATUS_ERROR) + ); + assert_eq!(text(&error), "Activities are not enabled on this worker"); + unsafe { + tpb_worker_free(w); + release(rt); + } + Ok(()) + } + + #[test] + fn calls_fail_while_finalize_holds_the_worker_and_shutdown_is_idempotent() -> Checked { + let rt = runtime(); + let w = replayer(rt, false)?; + let held = unsafe { &*w }.worker.clone().try_write_owned()?; + assert_eq!( + unsafe { tpb_record_activity_heartbeat(w, std::ptr::null(), 0) }, + CALL_FAILED + ); + assert_eq!(unsafe { tpb_worker_initiate_shutdown(w) }, CALL_FAILED); + assert_eq!(set_api_key(w, "key"), CALL_FAILED); + drop(held); + assert_eq!(unsafe { tpb_worker_initiate_shutdown(w) }, CALL_OK); + assert_eq!(unsafe { tpb_worker_initiate_shutdown(w) }, CALL_OK); + + unsafe { tpb_worker_finalize_shutdown(w, 1) }; + assert_eq!( + events(rt, 1), + vec![(1, KIND_SHUTDOWN_FINALIZED, STATUS_OK, vec![])] + ); + unsafe { + tpb_worker_free(w); + release(rt); + } + Ok(()) + } + + #[test] + fn finalized_worker_and_panicking_task_report_error_events() { + let rt = runtime(); + let queue = unsafe { &*rt }.queue.clone(); + let w = Box::into_raw(Box::new(TpbWorker { + worker: Arc::new(RwLock::new(None)), + shutdown_initiated: AtomicBool::new(false), + queue: queue.clone(), + })); + unsafe { + tpb_poll_workflow_activation(w, 1); + tpb_complete_activity_task(w, 2, std::ptr::null(), 0); + tpb_worker_finalize_shutdown(w, 3); + assert_eq!(tpb_worker_initiate_shutdown(w), CALL_FAILED); + assert_eq!(set_api_key(w, "key"), CALL_FAILED); + assert_eq!( + tpb_record_activity_heartbeat(w, std::ptr::null(), 0), + CALL_FAILED + ); + } + queue.spawn(4, KIND_ACTIVITY_TASK, error_status, async { + panic!("boom") + }); + + let finalized = FINALIZED.as_bytes().to_vec(); + assert_eq!( + events(rt, 4), + vec![ + (1, KIND_WORKFLOW_ACTIVATION, STATUS_ERROR, finalized.clone()), + (2, KIND_ACTIVITY_COMPLETED, STATUS_ERROR, finalized.clone()), + (3, KIND_SHUTDOWN_FINALIZED, STATUS_ERROR, finalized), + ( + 4, + KIND_ACTIVITY_TASK, + STATUS_ERROR, + b"Panic in temporal-php-bridge: boom".to_vec() + ), + ] + ); + unsafe { + tpb_worker_free(w); + release(rt); + } + } +} diff --git a/core/grpc/Call.php b/core/grpc/Call.php new file mode 100644 index 000000000..a89d51f62 --- /dev/null +++ b/core/grpc/Call.php @@ -0,0 +1,181 @@ +> */ + private array $metadata = []; + + private string $message = ''; + private ?CallCredentials $credentials = null; + private ?int $tag = null; + + /** @var array{?string, \stdClass}|null */ + private ?array $response = null; + + private bool $received = false; + + public function __construct( + private readonly Channel $channel, + private readonly string $method, + private readonly Timeval $deadline, + ) { + if ($channel->isClosed()) { + throw new \InvalidArgumentException('Call cannot be constructed from a closed Channel', self::CLOSED_ERROR_CODE); + } + } + + public function startBatch(array $ops): \stdClass + { + if ($this->channel->isClosed()) { + throw new \RuntimeException('startBatch Error. Channel is closed', self::CLOSED_ERROR_CODE); + } + $event = new \stdClass(); + if (\array_key_exists(OP_SEND_INITIAL_METADATA, $ops)) { + /** @var array> $metadata */ + $metadata = $ops[OP_SEND_INITIAL_METADATA]; + $this->metadata = $metadata; + $event->send_metadata = true; + } + if (\array_key_exists(OP_SEND_MESSAGE, $ops)) { + /** @var array{message: string} $message */ + $message = $ops[OP_SEND_MESSAGE]; + $this->message = $message['message']; + $event->send_message = true; + } + if (\array_key_exists(OP_SEND_CLOSE_FROM_CLIENT, $ops)) { + $this->start(); + $event->send_close = true; + } + if (\array_key_exists(OP_RECV_INITIAL_METADATA, $ops)) { + $event->metadata = []; + } + $receiveMessage = \array_key_exists(OP_RECV_MESSAGE, $ops); + $receiveStatus = \array_key_exists(OP_RECV_STATUS_ON_CLIENT, $ops); + if ($receiveMessage || $receiveStatus) { + [$message, $status] = $this->receive(); + if ($receiveMessage) { + $event->message = $message; + } + if ($receiveStatus) { + $event->status = $status; + } + } + + return $event; + } + + public function getPeer(): string + { + return $this->channel->getTarget(); + } + + public function cancel(): void + { + if ($this->tag !== null) { + $this->channel->forgetCall($this->tag); + $this->tag = null; + $this->response = [null, self::status(STATUS_CANCELLED, self::CANCELLED_DETAILS, [])]; + } + } + + public function setCredentials(CallCredentials $credentials): int + { + $this->credentials = $credentials; + + return CALL_OK; + } + + public function __destruct() + { + $this->cancel(); + } + + /** + * @param array> $metadata + */ + private static function status(int $code, string $details, array $metadata): \stdClass + { + $status = new \stdClass(); + $status->metadata = $metadata; + $status->code = $code; + $status->details = $details; + + return $status; + } + + private function start(): void + { + if ($this->credentials !== null && !$this->channel->isSecure()) { + $this->response = [null, self::status(STATUS_UNAUTHENTICATED, self::INSECURE_CREDENTIALS_DETAILS, [])]; + + return; + } + $metadata = $this->metadata; + if ($this->credentials !== null) { + $metadata = \array_merge_recursive($metadata, $this->credentialsMetadata($this->credentials)); + } + foreach ($metadata as $key => $values) { + if (\str_ends_with($key, self::BINARY_HEADER_SUFFIX)) { + $metadata[$key] = \array_map(\base64_encode(...), $values); + } + } + $timeoutMs = Timeval::compare($this->deadline, Timeval::infFuture()) === 0 ? 0 : \max(1, $this->deadline->millisecondsLeft()); + $this->tag = $this->channel->startCall($this->method, $this->message, $metadata, $timeoutMs); + } + + /** + * @return array> + */ + private function credentialsMetadata(CallCredentials $credentials): array + { + $slash = (int) \strrpos($this->method, '/'); + $authority = $this->channel->authority(); + if (\str_ends_with($authority, self::DEFAULT_PORT_SUFFIX)) { + $authority = \substr($authority, 0, -\strlen(self::DEFAULT_PORT_SUFFIX)); + } + + return $credentials->metadata( + self::SECURE_SCHEME . $authority . \substr($this->method, 0, $slash), + \substr($this->method, $slash + 1), + ); + } + + /** + * @return array{?string, \stdClass} + */ + private function receive(): array + { + if ($this->received || ($this->tag === null && $this->response === null)) { + throw new \LogicException('start_batch was called incorrectly', CALL_ERROR_TOO_MANY_OPERATIONS); + } + $this->received = true; + if ($this->response !== null) { + return $this->response; + } + $tag = $this->tag; + $this->tag = null; + [$code, $data] = $this->channel->finishCall($tag); + if ($code === STATUS_OK) { + return [$data, self::status($code, '', [])]; + } + /** @var array{1: int} $header */ + $header = \unpack('V', $data); + $length = $header[1]; + $details = \substr($data, self::LENGTH_PREFIX_BYTES + $length); + + return [null, self::status($code, \substr($data, self::LENGTH_PREFIX_BYTES, $length), $details === '' ? [] : [self::STATUS_DETAILS_HEADER => [$details]])]; + } +} diff --git a/core/grpc/CallCredentials.php b/core/grpc/CallCredentials.php new file mode 100644 index 000000000..e40a8e30d --- /dev/null +++ b/core/grpc/CallCredentials.php @@ -0,0 +1,28 @@ +> + */ + public function metadata(string $serviceUrl, string $methodName): array + { + $context = new \stdClass(); + $context->service_url = $serviceUrl; + $context->method_name = $methodName; + + return ($this->callback)($context); + } +} diff --git a/core/grpc/Channel.php b/core/grpc/Channel.php new file mode 100644 index 000000000..8a62e2076 --- /dev/null +++ b/core/grpc/Channel.php @@ -0,0 +1,170 @@ +|null */ + private readonly ?array $tls; + + private ?\FFI\CData $client = null; + private ?int $connecting = null; + private int $state = CHANNEL_IDLE; + private bool $closed = false; + + public function __construct(private readonly string $target, array $args) + { + /** @var ChannelCredentials|null $credentials */ + $credentials = $args['credentials'] ?? null; + /** @var string|null $serverName */ + $serverName = $args['grpc.ssl_target_name_override'] ?? null; + $this->tls = $credentials?->tls($serverName); + } + + public function getTarget(): string + { + $this->assertOpen('getTarget error.'); + + return \preg_match(self::SCHEME_PATTERN, $this->target) === 1 ? $this->target : self::DEFAULT_SCHEME . $this->target; + } + + public function getConnectivityState(bool $try_to_connect = false): int + { + $this->assertOpen('getConnectivityState error.'); + $this->poll(0); + $state = $this->state; + if ($try_to_connect && $this->connecting === null && $state !== CHANNEL_READY) { + $this->connecting = Bridge::shared()->startConnect($this->client(), BridgeConnection::CONNECT_TIMEOUT_MS); + $this->state = $state === CHANNEL_IDLE ? CHANNEL_CONNECTING : $state; + } + + return $state; + } + + public function watchConnectivityState(int $last_state, Timeval $deadline): bool + { + $this->assertOpen('watchConnectivityState error'); + if ($this->state === $last_state) { + $this->poll($deadline->millisecondsLeft()); + } + if ($this->state === $last_state) { + $deadline->sleepUntil(); + } + + return $this->state !== $last_state; + } + + public function close(): void + { + if (Bridge::started()) { + if ($this->connecting !== null) { + Bridge::shared()->forgetCall($this->connecting); + } + if ($this->client !== null) { + Bridge::shared()->freeClient($this->client); + } + } + $this->client = null; + $this->connecting = null; + $this->closed = true; + } + + /** + * @internal + */ + public function isClosed(): bool + { + return $this->closed; + } + + /** + * @internal + */ + public function isSecure(): bool + { + return $this->tls !== null; + } + + /** + * @internal + */ + public function authority(): string + { + return $this->tls['domain'] ?? (string) \preg_replace(self::AUTHORITY_PREFIX_PATTERN, '', $this->getTarget()); + } + + /** + * @internal + * @param array> $metadata + */ + public function startCall(string $method, string $message, array $metadata, int $timeoutMs): int + { + return Bridge::shared()->startCall($this->client(), $method, $message, $metadata, $timeoutMs); + } + + /** + * @internal + * @return array{int, string} + */ + public function finishCall(int $tag): array + { + $result = Bridge::shared()->awaitCall($tag); + $this->state = match ($result[0]) { + STATUS_OK => CHANNEL_READY, + STATUS_UNAVAILABLE => $this->state === CHANNEL_READY ? CHANNEL_IDLE : CHANNEL_TRANSIENT_FAILURE, + default => $this->state, + }; + + return $result; + } + + /** + * @internal + */ + public function forgetCall(int $tag): void + { + if (Bridge::started()) { + Bridge::shared()->forgetCall($tag); + } + } + + public function __destruct() + { + $this->close(); + } + + private function client(): \FFI\CData + { + return $this->client ??= Bridge::shared()->newClient(BridgeConnection::client($this->target, $this->tls)); + } + + private function poll(int $waitMs): void + { + if ($this->connecting === null) { + return; + } + $result = Bridge::shared()->pollCall($this->connecting, $waitMs); + if ($result === null) { + return; + } + $this->connecting = null; + $this->state = $result[0] === STATUS_OK ? CHANNEL_READY : CHANNEL_TRANSIENT_FAILURE; + } + + private function assertOpen(string $prefix): void + { + if ($this->closed) { + throw new \RuntimeException($prefix . 'Channel is already closed.', self::CLOSED_ERROR_CODE); + } + } +} diff --git a/core/grpc/ChannelCredentials.php b/core/grpc/ChannelCredentials.php new file mode 100644 index 000000000..6cbea4914 --- /dev/null +++ b/core/grpc/ChannelCredentials.php @@ -0,0 +1,47 @@ + + */ + public function tls(?string $serverName): array + { + return BridgeConnection::tls($this->rootCerts ?? self::$defaultRootsPem, $serverName, $this->certChain, $this->privateKey); + } +} diff --git a/core/grpc/Timeval.php b/core/grpc/Timeval.php new file mode 100644 index 000000000..650428a32 --- /dev/null +++ b/core/grpc/Timeval.php @@ -0,0 +1,56 @@ +microseconds <=> $b_timeval->microseconds; + } + + public function add(self $timeval): self + { + $sum = $this->microseconds + $timeval->microseconds; + if ($this->microseconds === \PHP_INT_MAX || $timeval->microseconds === \PHP_INT_MAX || !\is_int($sum)) { + return self::infFuture(); + } + + return new self($sum); + } + + public function sleepUntil(): void + { + $left = $this->microseconds - self::now()->microseconds; + if ($left > 0) { + \usleep($left); + } + } + + /** + * @internal + */ + public function millisecondsLeft(): int + { + $left = $this->microseconds - self::now()->microseconds; + + return $left > 0 ? \intdiv($left + self::MICROSECONDS_PER_MILLISECOND - 1, self::MICROSECONDS_PER_MILLISECOND) : 0; + } +} diff --git a/core/grpc/constants.php b/core/grpc/constants.php new file mode 100644 index 000000000..3ddd28818 --- /dev/null +++ b/core/grpc/constants.php @@ -0,0 +1,49 @@ + - - + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 241af9023..37546ba46 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -76,6 +76,10 @@ tests/Unit + + tests/Unit + tests/Core + tests/Functional tests/Functional/TimerOnlyTimeSkipTestCase.php @@ -117,6 +121,7 @@ src + testing/src \ No newline at end of file diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 421a41d7a..e7914cc71 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1407,11 +1407,6 @@ - - - - - diff --git a/psalm.xml b/psalm.xml index ef14c51ce..cdb1a99f0 100644 --- a/psalm.xml +++ b/psalm.xml @@ -18,6 +18,7 @@ + @@ -25,5 +26,20 @@ + + + + + + + + + + + + + + + diff --git a/src/Client/GRPC/BaseClient.php b/src/Client/GRPC/BaseClient.php index dd36b1713..35c94efed 100644 --- a/src/Client/GRPC/BaseClient.php +++ b/src/Client/GRPC/BaseClient.php @@ -71,8 +71,8 @@ final public function __construct(BaseStub|\Closure $serviceClient) */ public static function create(string $address): static { - if (!\extension_loaded('grpc')) { - throw new \RuntimeException('The gRPC extension is required to use Temporal Client.'); + if (!\extension_loaded('grpc') && !\extension_loaded('ffi')) { + throw new \RuntimeException('The gRPC or FFI extension is required to use Temporal Client.'); } return new static( @@ -101,8 +101,8 @@ public static function createSSL( ?string $clientPem = null, ?string $overrideServerName = null, ): static { - if (!\extension_loaded('grpc')) { - throw new \RuntimeException('The gRPC extension is required to use Temporal Client.'); + if (!\extension_loaded('grpc') && !\extension_loaded('ffi')) { + throw new \RuntimeException('The gRPC or FFI extension is required to use Temporal Client.'); } $loadCert = static function (?string $cert): ?string { @@ -270,9 +270,9 @@ private function call(string $method, object $arg, ContextInterface $ctx): objec do { ++$attempt; + $deadline = $ctx->getDeadline(); try { $options = $ctx->getOptions(); - $deadline = $ctx->getDeadline(); if ($deadline !== null) { $diff = (new \DateTime())->diff($deadline); $options['timeout'] = \max(0, (int) CarbonInterval::instance($diff)->totalMicroseconds); @@ -290,8 +290,10 @@ private function call(string $method, object $arg, ContextInterface $ctx): objec return $result; } catch (ServiceClientException $e) { + $deadlineReached = $deadline !== null && new \DateTimeImmutable() > $deadline; if (!\in_array($e->getCode(), self::RETRYABLE_ERRORS, true)) { - if ($e->getCode() === StatusCode::DEADLINE_EXCEEDED) { + if ($e->getCode() === StatusCode::DEADLINE_EXCEEDED + || ($deadlineReached && $e->getCode() === StatusCode::CANCELLED)) { throw new TimeoutException($e->getMessage(), $e->getCode(), $e); } @@ -303,16 +305,15 @@ private function call(string $method, object $arg, ContextInterface $ctx): objec throw $e; } + if ($deadlineReached) { + throw new TimeoutException('Call timeout has been reached'); + } + if ($retryOption->maximumAttempts !== 0 && $attempt >= $retryOption->maximumAttempts) { // Reached maximum attempts throw $e; } - if ($ctx->getDeadline() !== null && new \DateTimeImmutable() > $ctx->getDeadline()) { - // Deadline is reached - throw new TimeoutException('Call timeout has been reached'); - } - // Init interval values in milliseconds $initialIntervalMs ??= $retryOption->initialInterval === null ? (int) CarbonInterval::millisecond(50)->totalMilliseconds diff --git a/src/Internal/Activity/ActivityContext.php b/src/Internal/Activity/ActivityContext.php index 69b9e61f5..dcc73e3f1 100644 --- a/src/Internal/Activity/ActivityContext.php +++ b/src/Internal/Activity/ActivityContext.php @@ -43,8 +43,9 @@ public function __construct( private ValuesInterface $input, private HeaderInterface $header, private readonly ?ValuesInterface $lastHeartbeatDetails = null, + ?ActivityInfo $info = null, ) { - $this->info = new ActivityInfo(); + $this->info = $info ?? new ActivityInfo(); } public function getInfo(): ActivityInfo diff --git a/src/Internal/Bridge/Bridge.php b/src/Internal/Bridge/Bridge.php new file mode 100644 index 000000000..2c8785dad --- /dev/null +++ b/src/Internal/Bridge/Bridge.php @@ -0,0 +1,391 @@ + LogLevel::ERROR, 'WARN' => LogLevel::WARNING, 'INFO' => LogLevel::INFO]; + private const HEADER = __DIR__ . '/../../../core/bridge/include/temporal_php_bridge.h'; + + private static ?self $shared = null; + private static int $sharedPid = 0; + private readonly \FFI $ffi; + private readonly \FFI\CData $runtime; + private readonly \FFI\CData $events; + + /** @var list */ + private array $backlog = []; + + /** @var array */ + private array $rpcResults = []; + + private int $rpcTag = 0; + private LoggerInterface $logger; + + public function __construct() + { + $this->logger = new StderrLogger(); + $library = CoreEnvironment::string(CoreEnvironment::BRIDGE_LIB) + ?? \dirname(__DIR__, 3) . '/core/bridge/target/release/libtemporal_php_bridge.' . (\PHP_OS_FAMILY === 'Darwin' ? 'dylib' : 'so'); + if (!\is_readable($library)) { + throw new \RuntimeException(\sprintf('Unable to read the sdk-core bridge library "%s", build it with `cargo build --release` in core/bridge or set TEMPORAL_CORE_BRIDGE_LIB; the gRPC client uses ext-grpc instead when it is installed', $library)); + } + + $this->ffi = \FFI::cdef((string) \file_get_contents(self::HEADER), $library); + $config = self::json([ + 'threads' => CoreEnvironment::integer(CoreEnvironment::THREADS, self::DEFAULT_THREADS, 1), + 'log' => CoreEnvironment::string(CoreEnvironment::LOG) ?? self::DEFAULT_LOG_FILTER, + 'prometheus' => CoreEnvironment::string(CoreEnvironment::PROMETHEUS), + 'otel' => self::otel(), + 'metric_prefix' => CoreEnvironment::string(CoreEnvironment::METRIC_PREFIX), + 'global_tags' => CoreEnvironment::map(CoreEnvironment::METRIC_GLOBAL_TAGS), + 'worker_heartbeat_interval_ms' => CoreEnvironment::integer(CoreEnvironment::WORKER_HEARTBEAT_INTERVAL, self::DEFAULT_WORKER_HEARTBEAT_INTERVAL_MS, 0) ?: null, + ]); + $this->runtime = $this->construct('tpb_runtime_new', $config, \strlen($config)); + /** @var \FFI\CData $events */ + $events = $this->ffi->new(\sprintf('TpbEvent[%d]', self::EVENT_BUFFER_SIZE)); + $this->events = $events; + } + + public static function shared(): self + { + if (self::$shared !== null && self::$sharedPid !== (int) \getmypid()) { + throw new \LogicException(self::FORKED_AFTER_START); + } + if (self::$shared === null) { + self::$shared = new self(); + self::$sharedPid = (int) \getmypid(); + } + + return self::$shared; + } + + public static function started(): bool + { + return self::$shared !== null && self::$sharedPid === (int) \getmypid(); + } + + public function useLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } + + public function newWorker(array $config): \FFI\CData + { + $json = self::json($config); + + return $this->construct('tpb_worker_new', $this->runtime, $json, \strlen($json)); + } + + public function newReplayer(array $config, string $history, string $workflowId): \FFI\CData + { + $json = self::json($config); + + return $this->construct('tpb_replayer_new', $this->runtime, $json, \strlen($json), $history, \strlen($history), $workflowId, \strlen($workflowId)); + } + + public function newClient(array $config): \FFI\CData + { + $json = self::json($config); + + return $this->construct('tpb_client_new', $this->runtime, $json, \strlen($json)); + } + + public function freeClient(\FFI\CData $client): void + { + $this->ffi->tpb_client_free($client); + } + + /** + * @param array> $metadata + */ + public function startCall(\FFI\CData $client, string $path, string $request, array $metadata, int $timeoutMs): int + { + $tag = ++$this->rpcTag; + $json = $metadata === [] ? '' : \json_encode($metadata, \JSON_THROW_ON_ERROR); + $this->rpcResults[$tag] = null; + $this->ffi->tpb_client_call($client, $tag, $path, \strlen($path), $request, \strlen($request), $json, \strlen($json), $timeoutMs); + + return $tag; + } + + public function startConnect(\FFI\CData $client, int $timeoutMs): int + { + $tag = ++$this->rpcTag; + $this->rpcResults[$tag] = null; + $this->ffi->tpb_client_connect($client, $tag, $timeoutMs); + + return $tag; + } + + /** + * @return array{int, string}|null + */ + public function pollCall(int $tag, int $timeoutMs): ?array + { + $deadline = self::nowMilliseconds() + $timeoutMs; + do { + $left = \max(0, $deadline - self::nowMilliseconds()); + \array_push($this->backlog, ...$this->fetch(\min($left, self::POLL_TIMEOUT_MS))); + } while (!isset($this->rpcResults[$tag]) && self::nowMilliseconds() < $deadline); + $result = $this->rpcResults[$tag] ?? null; + if ($result !== null) { + unset($this->rpcResults[$tag]); + } + + return $result; + } + + /** + * @return array{int, string} + */ + public function awaitCall(int $tag): array + { + while (!isset($this->rpcResults[$tag])) { + \array_push($this->backlog, ...$this->fetch(self::POLL_TIMEOUT_MS)); + } + /** @var array{int, string} $result */ + $result = $this->rpcResults[$tag]; + unset($this->rpcResults[$tag]); + + return $result; + } + + public function forgetCall(int $tag): void + { + unset($this->rpcResults[$tag]); + } + + public function pollWorkflowActivation(\FFI\CData $worker, int $tag): void + { + $this->ffi->tpb_poll_workflow_activation($worker, $tag); + } + + public function pollActivityTask(\FFI\CData $worker, int $tag): void + { + $this->ffi->tpb_poll_activity_task($worker, $tag); + } + + public function completeWorkflowActivation(\FFI\CData $worker, int $tag, string $completion): void + { + $this->ffi->tpb_complete_workflow_activation($worker, $tag, $completion, \strlen($completion)); + } + + public function completeActivityTask(\FFI\CData $worker, int $tag, string $completion): void + { + $this->ffi->tpb_complete_activity_task($worker, $tag, $completion, \strlen($completion)); + } + + public function recordActivityHeartbeat(\FFI\CData $worker, string $heartbeat): void + { + if ($this->ffi->tpb_record_activity_heartbeat($worker, $heartbeat, \strlen($heartbeat)) !== self::CALL_OK) { + throw new \RuntimeException('Unable to record the activity heartbeat: the sdk-core worker is finalized or the heartbeat is invalid'); + } + } + + public function setApiKey(\FFI\CData $worker, string $apiKey): void + { + if ($this->ffi->tpb_worker_set_api_key($worker, $apiKey, \strlen($apiKey)) !== self::CALL_OK) { + throw new \RuntimeException('Unable to set the API key: the sdk-core worker has no connection or is finalized'); + } + } + + public function initiateShutdown(\FFI\CData $worker): void + { + $this->ffi->tpb_worker_initiate_shutdown($worker); + } + + /** + * @param array $workers + * @return array + */ + public function shutdownWorkers(array $workers): array + { + foreach ($workers as $tag => $worker) { + $this->ffi->tpb_worker_finalize_shutdown($worker, $tag); + } + + $pending = $workers; + $errors = []; + $deadline = \microtime(true) + self::FINALIZE_TIMEOUT_SECONDS; + while ($pending !== [] && \microtime(true) < $deadline) { + foreach ($this->nextEvents(self::POLL_TIMEOUT_MS) as [$tag, $kind, $status, $data]) { + if (isset($workers[$tag]) && $status !== self::STATUS_OK && $status !== self::STATUS_SHUTDOWN) { + $errors[$tag][] = $data; + } + if ($kind === self::KIND_SHUTDOWN_FINALIZED) { + unset($pending[$tag]); + } + } + } + foreach (\array_keys($pending) as $tag) { + $errors[$tag][] = 'The sdk-core worker did not finalize in time'; + } + foreach ($workers as $worker) { + $this->ffi->tpb_worker_free($worker); + } + + return \array_map(static fn(array $messages): string => \implode('; ', $messages), $errors); + } + + /** + * @return list + */ + public function nextEvents(int $timeoutMs): array + { + if ($this->backlog === []) { + return $this->fetch($timeoutMs); + } + + $events = [...$this->backlog, ...$this->fetch(0)]; + $this->backlog = []; + + return $events; + } + + /** + * @return list + */ + public function peekEvents(): array + { + $events = $this->fetch(0); + \array_push($this->backlog, ...$events); + + return $events; + } + + private static function nowMilliseconds(): int + { + return \intdiv(\hrtime(true), self::NANOSECONDS_PER_MILLISECOND); + } + + private static function otel(): ?array + { + $url = CoreEnvironment::string(CoreEnvironment::OTEL_URL); + if ($url === null) { + return null; + } + + return [ + 'url' => $url, + 'headers' => CoreEnvironment::map(CoreEnvironment::OTEL_HEADERS), + 'metric_periodicity_ms' => CoreEnvironment::integer(CoreEnvironment::OTEL_METRIC_PERIODICITY, self::DEFAULT_OTEL_METRIC_PERIODICITY_MS, 1), + 'protocol' => CoreEnvironment::string(CoreEnvironment::OTEL_PROTOCOL) ?? self::DEFAULT_OTEL_PROTOCOL, + 'use_seconds_for_durations' => CoreEnvironment::flag(CoreEnvironment::OTEL_USE_SECONDS_FOR_DURATIONS, false), + ]; + } + + private static function json(array $value): string + { + return \json_encode($value, \JSON_THROW_ON_ERROR); + } + + /** + * @psalm-suppress UndefinedPropertyFetch + */ + private function construct(string $function, \FFI\CData|string|int ...$arguments): \FFI\CData + { + /** @var \FFI\CData $err */ + $err = $this->ffi->new('uint8_t*'); + /** @var \FFI\CData $errLen */ + $errLen = $this->ffi->new('size_t'); + $object = $this->ffi->$function(...[...$arguments, \FFI::addr($err), \FFI::addr($errLen)]); + + if ($object === null) { + throw new \RuntimeException(\sprintf('%s failed: %s', $function, $this->take($err, $errLen->cdata))); + } + + return $object; + } + + /** + * @return list + */ + private function fetch(int $timeoutMs): array + { + $result = []; + do { + $count = $this->ffi->tpb_next_events($this->runtime, $timeoutMs, $this->events, self::EVENT_BUFFER_SIZE); + $timeoutMs = 0; + for ($i = 0; $i < $count; ++$i) { + $event = $this->events[$i]; + $data = $this->take($event->data, $event->len); + if ($event->kind === self::KIND_LOG) { + $this->log($data); + continue; + } + if ($event->kind !== self::KIND_RPC_RESULT) { + $result[] = [$event->tag, $event->kind, $event->status, $data]; + continue; + } + if (!\array_key_exists($event->tag, $this->rpcResults)) { + continue; + } + $grpcCode = $event->status; + $this->rpcResults[$event->tag] = [$grpcCode, $data]; + } + } while ($count === self::EVENT_BUFFER_SIZE); + + return $result; + } + + private function log(string $json): void + { + /** @var array{level: string, target: string, message: string, fields: array} $entry */ + $entry = \json_decode($json, true, flags: \JSON_THROW_ON_ERROR); + $this->logger->log( + self::LOG_LEVELS[$entry['level']] ?? LogLevel::DEBUG, + $entry['message'], + ['target' => $entry['target']] + $entry['fields'], + ); + } + + private function take(?\FFI\CData $data, int $len): string + { + if ($len === 0 || $data === null) { + return ''; + } + + $bytes = \FFI::string($data, $len); + $this->ffi->tpb_bytes_free($data, $len); + + return $bytes; + } +} diff --git a/src/Internal/Bridge/BridgeConnection.php b/src/Internal/Bridge/BridgeConnection.php new file mode 100644 index 000000000..ba8dc489d --- /dev/null +++ b/src/Internal/Bridge/BridgeConnection.php @@ -0,0 +1,62 @@ +|null $tls + * @return array{target_url: string, tls: array|null, connect_timeout_ms: int} + */ + public static function client(string $address, ?array $tls): array + { + return [ + 'target_url' => ($tls === null ? 'http://' : 'https://') . $address, + 'tls' => $tls, + 'connect_timeout_ms' => self::CONNECT_TIMEOUT_MS, + ]; + } + + /** + * @return array + */ + public static function tls(?string $rootCerts, ?string $serverName, ?string $certChain, ?string $privateKey): array + { + return [ + 'server_root_ca_cert' => self::certificate($rootCerts), + 'domain' => $serverName, + 'client_cert' => self::certificate($certChain), + 'client_private_key' => self::certificate($privateKey), + ]; + } + + private static function certificate(?string $fileOrPem): ?string + { + if ($fileOrPem === null || $fileOrPem === '') { + return null; + } + if (!\is_file($fileOrPem)) { + return $fileOrPem; + } + $content = \file_get_contents($fileOrPem); + if ($content === false) { + throw new \InvalidArgumentException("Failed to load certificate from file `$fileOrPem`."); + } + + return $content; + } +} diff --git a/src/Internal/Bridge/CoreEnvironment.php b/src/Internal/Bridge/CoreEnvironment.php new file mode 100644 index 000000000..bf228d1fe --- /dev/null +++ b/src/Internal/Bridge/CoreEnvironment.php @@ -0,0 +1,119 @@ + ['min_range' => $min]]); + if ($integer === false) { + throw new \InvalidArgumentException(\sprintf('%s must be an integer not less than %d, "%s" given', $name, $min, $value)); + } + + return $integer; + } + + public static function flag(string $name, bool $default): bool + { + $value = self::string($name); + if ($value === null) { + return $default; + } + + $flag = \filter_var($value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE); + if ($flag === null) { + throw new \InvalidArgumentException(\sprintf('%s must be a boolean, "%s" given', $name, $value)); + } + + return $flag; + } + + public static function fraction(string $name): ?float + { + $value = self::string($name); + if ($value === null) { + return null; + } + + $fraction = \filter_var($value, \FILTER_VALIDATE_FLOAT, ['options' => ['min_range' => 0, 'max_range' => 1]]); + if ($fraction === false) { + throw new \InvalidArgumentException(\sprintf('%s must be a number from 0 to 1, "%s" given', $name, $value)); + } + + return $fraction; + } + + /** + * @return array|null + */ + public static function map(string $name): ?array + { + $value = self::string($name); + if ($value === null) { + return null; + } + + $map = []; + foreach (\explode(',', $value) as $pair) { + $parts = \explode('=', $pair, 2); + $key = \trim($parts[0]); + if ($key === '' || !isset($parts[1])) { + throw new \InvalidArgumentException(\sprintf('%s must be a comma-separated list of key=value pairs, "%s" given', $name, $value)); + } + $map[$key] = \trim($parts[1]); + } + + return $map; + } +} diff --git a/src/Internal/Transport/Router/InvokeActivity.php b/src/Internal/Transport/Router/InvokeActivity.php index ffa1f537d..41b63462e 100644 --- a/src/Internal/Transport/Router/InvokeActivity.php +++ b/src/Internal/Transport/Router/InvokeActivity.php @@ -53,9 +53,13 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred $header = $request->getHeader(); $heartbeatDetails = null; - // always in binary format - $taskToken = $options['info']['TaskToken'] ?? ''; - $options['info']['TaskToken'] = \base64_decode($taskToken); + $info = $options['info'] ?? null; + if (!$info instanceof ActivityInfo) { + $info = null; + // always in binary format + $taskToken = $options['info']['TaskToken'] ?? ''; + $options['info']['TaskToken'] = \base64_decode($taskToken); + } if (($options['heartbeatDetails'] ?? 0) !== 0) { $offset = \count($payloads) - ($options['heartbeatDetails'] ?? 0); @@ -70,9 +74,12 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred $payloads, $header, $heartbeatDetails, + $info, ); - /** @var ActivityContext $context */ - $context = $this->services->marshaller->unmarshal($options, $context); + if ($info === null) { + /** @var ActivityContext $context */ + $context = $this->services->marshaller->unmarshal($options, $context); + } $prototype = $this->findDeclarationOrFail($context->getInfo()); diff --git a/src/Internal/Transport/Router/StartWorkflow.php b/src/Internal/Transport/Router/StartWorkflow.php index 14e12083e..692aef719 100644 --- a/src/Internal/Transport/Router/StartWorkflow.php +++ b/src/Internal/Transport/Router/StartWorkflow.php @@ -54,15 +54,20 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred $payloads = EncodedValues::sliceValues($this->services->dataConverter, $payloads, 0, $offset); } - // Search Attributes and Typed Search Attributes - $searchAttributes = $this->convertSearchAttributes($options['info']['SearchAttributes'] ?? null); - $memo = $this->convertMemo($options['info']['Memo'] ?? null); - $options['info']['SearchAttributes'] = $searchAttributes?->getValues(); - $options['info']['TypedSearchAttributes'] = $this->prepareTypedSA($options['search_attributes'] ?? null); - $options['info']['Memo'] = $memo?->getValues(); - - /** @var Input $input */ - $input = $this->services->marshaller->unmarshal($options, new Input()); + $info = $options['info'] ?? null; + if ($info instanceof WorkflowInfo) { + $input = new Input($info); + } else { + // Search Attributes and Typed Search Attributes + $searchAttributes = $this->convertSearchAttributes($options['info']['SearchAttributes'] ?? null); + $memo = $this->convertMemo($options['info']['Memo'] ?? null); + $options['info']['SearchAttributes'] = $searchAttributes?->getValues(); + $options['info']['TypedSearchAttributes'] = $this->prepareTypedSA($options['search_attributes'] ?? null); + $options['info']['Memo'] = $memo?->getValues(); + + /** @var Input $input */ + $input = $this->services->marshaller->unmarshal($options, new Input()); + } /** @psalm-suppress InaccessibleProperty */ $input->input = $payloads; diff --git a/src/Worker/Core/ActivityTasks.php b/src/Worker/Core/ActivityTasks.php new file mode 100644 index 000000000..0e939a4b7 --- /dev/null +++ b/src/Worker/Core/ActivityTasks.php @@ -0,0 +1,217 @@ + */ + private array $running = []; + + /** + * @var \Closure(list, array): list + * @psalm-suppress PropertyNotSetInConstructor + */ + private \Closure $dispatch; + + /** @psalm-suppress PropertyNotSetInConstructor */ + private PayloadMapper $payloads; + + /** @psalm-suppress PropertyNotSetInConstructor */ + private InfoFactory $info; + + public function bind(Bridge $bridge, \Closure $dispatch, DataConverterInterface $converter): void + { + $this->bridge = $bridge; + $this->dispatch = $dispatch; + $this->payloads = new PayloadMapper($converter); + $this->info = new InfoFactory($this->payloads); + } + + public function handle(\FFI\CData $worker, string $taskQueue, string $bytes): ?string + { + $task = new ActivityTask(); + try { + $task->mergeFromString($bytes); + if ($task->getVariant() !== 'start') { + $this->cancel($task); + return null; + } + + /** @var Start $start */ + $start = $task->getStart(); + $result = $this->execute($worker, $taskQueue, $task->getTaskToken(), $start); + } catch (\Throwable $e) { + if ($task->getTaskToken() === '') { + throw $e; + } + $result = new ActivityExecutionResult(['failed' => new ActivityFailure(['failure' => $this->payloads->failure($e)])]); + } + + return (new ActivityTaskCompletion(['task_token' => $task->getTaskToken(), 'result' => $result]))->serializeToString(); + } + + public function call(string $method, $payload): array + { + if ($method !== 'temporal.RecordActivityHeartbeat') { + throw new TransportException(\sprintf('RPC method "%s" is not supported by the sdk-core transport', $method)); + } + + $token = \base64_decode($payload['taskToken']); + if (!isset($this->running[$token])) { + return []; + } + + $details = new Payloads(); + $details->mergeFromString(\base64_decode($payload['details'])); + $this->bridge->recordActivityHeartbeat( + $this->running[$token][0], + (new ActivityHeartbeat(['task_token' => $token, 'details' => $details->getPayloads()]))->serializeToString(), + ); + + foreach ($this->bridge->peekEvents() as [, $kind, $status, $data]) { + if ($kind === Bridge::KIND_ACTIVITY_TASK && $status === Bridge::STATUS_OK) { + $task = new ActivityTask(); + $task->mergeFromString($data); + $this->cancel($task); + } + } + + $cancel = $this->running[$token][1] ?? null; + if ($cancel === null) { + return []; + } + + return match ($cancel->getReason()) { + ActivityCancelReason::PAUSED => ['paused' => true], + ActivityCancelReason::RESET => ['reset' => true], + default => ['canceled' => true], + }; + } + + private function execute(\FFI\CData $worker, string $taskQueue, string $token, Start $start): ActivityExecutionResult + { + if ($start->getActivityType() === self::SIDE_EFFECT) { + /** @var \Traversable&\ArrayAccess&\Countable $input */ + $input = $start->getInput(); + + return new ActivityExecutionResult(['completed' => new ActivitySuccess(['result' => \count($input) > 0 ? $input[0] : null])]); + } + + $this->running[$token] = [$worker, null]; + + try { + $responses = ($this->dispatch)([$this->request($token, $start, $taskQueue)], ['taskQueue' => $taskQueue]); + + return $this->result($responses, $this->running[$token][1]); + } finally { + unset($this->running[$token]); + } + } + + private function cancel(ActivityTask $task): void + { + if ($task->getVariant() === 'cancel' && isset($this->running[$task->getTaskToken()])) { + $this->running[$task->getTaskToken()][1] = $task->getCancel(); + } + } + + /** + * @param list $responses + */ + private function result(array $responses, ?Cancel $cancel): ActivityExecutionResult + { + foreach ($responses as $response) { + if ($response instanceof SuccessClientResponse) { + return new ActivityExecutionResult(['completed' => new ActivitySuccess([ + 'result' => $this->payloads->firstPayload($response->getPayloads()), + ])]); + } + + if ($response instanceof FailedClientResponse) { + $error = $response->getFailure(); + if ($error instanceof DoNotCompleteOnResultException) { + return new ActivityExecutionResult(['will_complete_async' => new WillCompleteAsync()]); + } + + if ($cancel !== null && ($error instanceof ActivityCanceledException || $error instanceof CanceledFailure)) { + return new ActivityExecutionResult(['cancelled' => new Cancellation([ + 'failure' => $this->payloads->failure(new CanceledFailure($error->getMessage())), + ])]); + } + + return new ActivityExecutionResult(['failed' => new ActivityFailure([ + 'failure' => $this->payloads->failure($error), + ])]); + } + } + + throw new \LogicException('Activity produced no result'); + } + + /** + * @psalm-suppress ArgumentTypeCoercion + */ + private function request(string $token, Start $start, string $taskQueue): ServerRequest + { + /** @var \Traversable&\ArrayAccess&\Countable $input */ + $input = $start->getInput(); + /** @var \Traversable&\Countable $details */ + $details = $start->getHeartbeatDetails(); + $payloads = \count($details) === 0 ? $input : new \ArrayIterator([...$input, ...$details]); + + return new ServerRequest( + name: $start->getIsLocal() ? 'InvokeLocalActivity' : 'InvokeActivity', + info: new TickInfo(new \DateTimeImmutable()), + options: [ + 'name' => $start->getActivityType(), + 'info' => $this->info->activityInfo($token, $start, $taskQueue), + 'heartbeatDetails' => \count($details), + ], + payloads: $this->payloads->values($payloads), + id: $start->getActivityId(), + header: $this->payloads->header($start->getHeaderFields()), + ); + } +} diff --git a/src/Worker/Core/CommandTranslator.php b/src/Worker/Core/CommandTranslator.php new file mode 100644 index 000000000..a8f935624 --- /dev/null +++ b/src/Worker/Core/CommandTranslator.php @@ -0,0 +1,556 @@ + $commands + * @return list + */ + public function translate(RunState $run, CommandInterface $command, TickInfo $tick, array &$commands): array + { + if ($command instanceof SuccessClientResponse) { + return []; + } + + if ($command instanceof FailedClientResponse) { + throw $command->getFailure(); + } + + if ($command instanceof Request\UndefinedResponse) { + throw new \LogicException($command->getOptions()['message'] ?? 'Undefined response'); + } + + if ($command instanceof UpdateResponse) { + return self::emit($commands, $this->updateResponse($run, $command)); + } + + \assert($command instanceof RequestInterface); + + return match ($command->getName()) { + Request\ExecuteActivity::NAME => self::emit($commands, $this->executeActivity($run, $command)), + Request\ExecuteLocalActivity::NAME => self::emit($commands, $this->executeLocalActivity($run, $command, $tick)), + Request\NewTimer::NAME => $this->newTimer($run, $command, $tick, $commands), + Request\CompleteWorkflow::NAME => self::emit($commands, $this->completeWorkflow($command)), + Request\ContinueAsNew::NAME => self::emit($commands, $this->continueAsNew($command)), + Request\Cancel::NAME => $this->cancel($run, $command, $tick, $commands), + Request\GetVersion::NAME => $this->getVersion($run, $command, $tick, $commands), + Request\SideEffect::NAME => self::emit($commands, $this->sideEffect($run, $command, $tick)), + Request\ExecuteChildWorkflow::NAME => self::emit($commands, $this->executeChildWorkflow($run, $command)), + Request\GetChildWorkflowExecution::NAME => $this->childExecution($run, $command, $tick), + Request\SignalExternalWorkflow::NAME => self::emit($commands, $this->signalExternal($run, $command)), + Request\CancelExternalWorkflow::NAME => self::emit($commands, $this->cancelExternal($run, $command)), + Request\UpsertSearchAttributes::NAME => self::emit($commands, $this->upsertSearchAttributes($command)), + Request\UpsertTypedSearchAttributes::NAME => self::emit($commands, $this->upsertTypedSearchAttributes($command)), + Request\UpsertMemo::NAME => self::emit($commands, $this->upsertMemo($command)), + Request\Panic::NAME => self::emit($commands, $this->panic($command)), + default => throw new \LogicException(\sprintf('Command "%s" is not supported by the sdk-core transport', $command->getName())), + }; + } + + /** + * @param list $commands + * @return array{} + */ + private static function emit(array &$commands, WorkflowCommand $command): array + { + $commands[] = $command; + + return []; + } + + /** + * @param array $fields + */ + private static function searchAttributes(array $fields): WorkflowCommand + { + return new WorkflowCommand(['upsert_workflow_search_attributes' => new UpsertWorkflowSearchAttributes([ + 'search_attributes' => new SearchAttributes(['indexed_fields' => $fields]), + ])]); + } + + private static function searchAttributeType(ValueType $type): string + { + return match ($type) { + ValueType::Bool => 'Bool', + ValueType::Float => 'Double', + ValueType::Int => 'Int', + ValueType::Keyword => 'Keyword', + ValueType::KeywordList => 'KeywordList', + ValueType::Text => 'Text', + ValueType::Datetime => 'Datetime', + }; + } + + private function executeActivity(RunState $run, RequestInterface $command): WorkflowCommand + { + /** @var array{name: string, options: array{ActivityID?: string, TaskQueueName?: string|null, Summary?: string, ...}} $request */ + $request = $command->getOptions(); + $options = $request['options']; + $seq = $run->bind($command->getID(), RunState::ACTIVITY); + $cancellationType = ($options['WaitForCancellation'] ?? false) === true + ? ActivityCancellationType::WAIT_CANCELLATION_COMPLETED + : ActivityCancellationType::TRY_CANCEL; + if ($cancellationType === ActivityCancellationType::TRY_CANCEL) { + $run->markTryCancel($seq); + } + + return new WorkflowCommand([ + 'schedule_activity' => new ScheduleActivity([ + 'seq' => $seq, + 'activity_id' => (string) (($options['ActivityID'] ?? '') ?: $seq), + 'activity_type' => $request['name'], + 'task_queue' => ($options['TaskQueueName'] ?? '') ?: $this->taskQueue, + 'headers' => $this->payloads->headerFields($command), + 'arguments' => $this->payloads->payloads($command->getPayloads()), + 'schedule_to_close_timeout' => ProtoTime::optionalDuration($options['ScheduleToCloseTimeout'] ?? 0), + 'schedule_to_start_timeout' => ProtoTime::optionalDuration($options['ScheduleToStartTimeout'] ?? 0), + 'start_to_close_timeout' => ProtoTime::optionalDuration($options['StartToCloseTimeout'] ?? 0), + 'heartbeat_timeout' => ProtoTime::optionalDuration($options['HeartbeatTimeout'] ?? 0), + 'retry_policy' => $this->retryPolicy($options['RetryPolicy'] ?? null), + 'cancellation_type' => $cancellationType, + 'priority' => $this->priority($options['Priority'] ?? null), + ]), + 'user_metadata' => $this->userMetadata($options['Summary'] ?? ''), + ]); + } + + private function executeLocalActivity(RunState $run, RequestInterface $command, TickInfo $tick): WorkflowCommand + { + /** @var array{name: string, options: array{Summary?: string, ...}} $request */ + $request = $command->getOptions(); + $options = $request['options']; + + return $this->localActivity($run, $request['name'], $command, $tick, $options['Summary'] ?? '', [ + 'headers' => $this->payloads->headerFields($command), + 'schedule_to_close_timeout' => ProtoTime::optionalDuration($options['ScheduleToCloseTimeout'] ?? 0), + 'start_to_close_timeout' => ProtoTime::optionalDuration($options['StartToCloseTimeout'] ?? 0), + 'retry_policy' => $this->retryPolicy($options['RetryPolicy'] ?? null), + 'cancellation_type' => ActivityCancellationType::WAIT_CANCELLATION_COMPLETED, + ]); + } + + private function sideEffect(RunState $run, RequestInterface $command, TickInfo $tick): WorkflowCommand + { + return $this->localActivity($run, ActivityTasks::SIDE_EFFECT, $command, $tick, $command->getOptions()['summary'] ?? '', [ + 'schedule_to_close_timeout' => new Duration(['seconds' => self::SIDE_EFFECT_TIMEOUT_SECONDS]), + ]); + } + + private function localActivity(RunState $run, string $type, RequestInterface $command, TickInfo $tick, string $summary, array $fields): WorkflowCommand + { + $seq = $run->bind($command->getID(), RunState::LOCAL_ACTIVITY); + + return new WorkflowCommand([ + 'schedule_local_activity' => $run->localActivities->schedule(new ScheduleLocalActivity([ + 'seq' => $seq, + 'activity_id' => (string) $seq, + 'activity_type' => $type, + 'attempt' => self::FIRST_ATTEMPT, + 'original_schedule_time' => ProtoTime::timestamp($tick->time), + 'arguments' => $this->payloads->payloads($command->getPayloads()), + ] + $fields)), + 'user_metadata' => $this->userMetadata($summary), + ]); + } + + /** + * @param list $commands + * @return list + */ + private function newTimer(RunState $run, RequestInterface $command, TickInfo $tick, array &$commands): array + { + $options = $command->getOptions(); + $ms = (int) ($options['ms'] ?? 0); + if ($ms < 0) { + return [new FailureResponse( + new ApplicationFailure(\sprintf(self::NEGATIVE_TIMER_MESSAGE, $ms), self::NEGATIVE_TIMER_TYPE, false), + $command->getID(), + $tick, + )]; + } + if ($ms === 0) { + return [new SuccessResponse(null, $command->getID(), $tick)]; + } + + return self::emit($commands, new WorkflowCommand([ + 'start_timer' => new StartTimer([ + 'seq' => $run->bind($command->getID(), RunState::TIMER), + 'start_to_fire_timeout' => ProtoTime::duration($ms * ProtoTime::NANOS_PER_MILLISECOND), + ]), + 'user_metadata' => $this->userMetadata($options['summary'] ?? ''), + ])); + } + + /** + * @param list $commands + * @return list + */ + private function cancel(RunState $run, RequestInterface $command, TickInfo $tick, array &$commands): array + { + $responses = []; + foreach ($command->getOptions()['ids'] ?? [] as $requestId) { + $bound = $run->command($requestId); + if ($bound === null) { + continue; + } + + [$type, $seq] = $bound; + switch ($type) { + case RunState::TIMER: + $run->release($seq); + $run->localActivities->forget($seq); + $commands[] = new WorkflowCommand(['cancel_timer' => new CancelTimer(['seq' => $seq])]); + $responses[] = new FailureResponse(new CanceledFailure('canceled'), $requestId, $tick); + break; + case RunState::ACTIVITY: + $commands[] = new WorkflowCommand(['request_cancel_activity' => new RequestCancelActivity(['seq' => $seq])]); + break; + case RunState::LOCAL_ACTIVITY: + $commands[] = new WorkflowCommand(['request_cancel_local_activity' => new RequestCancelLocalActivity(['seq' => $seq])]); + break; + case RunState::CHILD: + $commands[] = new WorkflowCommand(['cancel_child_workflow_execution' => new CancelChildWorkflowExecution([ + 'child_workflow_seq' => $seq, + ])]); + break; + } + } + + return [...$responses, new SuccessResponse(null, $command->getID(), $tick)]; + } + + /** + * @param list $commands + * @return list + */ + private function getVersion(RunState $run, RequestInterface $command, TickInfo $tick, array &$commands): array + { + /** @var array{changeID: string, minSupported: int, maxSupported: int} $options */ + $options = $command->getOptions(); + $version = $run->patches->version( + $options['changeID'], + $options['minSupported'], + $options['maxSupported'], + $tick->isReplaying, + $commands, + ); + + return [new SuccessResponse($this->payloads->encode([$version]), $command->getID(), $tick)]; + } + + private function completeWorkflow(RequestInterface $command): WorkflowCommand + { + $failure = $command->getFailure(); + if ($failure === null) { + return new WorkflowCommand(['complete_workflow_execution' => new CompleteWorkflowExecution([ + 'result' => $this->payloads->firstPayload($command->getPayloads()), + ])]); + } + + if ($failure instanceof CanceledFailure) { + return new WorkflowCommand(['cancel_workflow_execution' => new CancelWorkflowExecution()]); + } + + return new WorkflowCommand(['fail_workflow_execution' => new FailWorkflowExecution([ + 'failure' => $this->payloads->failure($failure), + ])]); + } + + private function continueAsNew(RequestInterface $command): WorkflowCommand + { + /** @var array{name: string, options?: array{TaskQueueName?: string, ...}} $request */ + $request = $command->getOptions(); + $options = $request['options'] ?? []; + + return new WorkflowCommand(['continue_as_new_workflow_execution' => new ContinueAsNewWorkflowExecution([ + 'workflow_type' => $request['name'], + 'task_queue' => ($options['TaskQueueName'] ?? '') ?: $this->taskQueue, + 'arguments' => $this->payloads->payloads($command->getPayloads()), + 'workflow_run_timeout' => ProtoTime::optionalDuration($options['WorkflowRunTimeout'] ?? 0), + 'workflow_task_timeout' => ProtoTime::optionalDuration($options['WorkflowTaskTimeout'] ?? 0), + 'headers' => $this->payloads->headerFields($command), + ])]); + } + + private function executeChildWorkflow(RunState $run, RequestInterface $command): WorkflowCommand + { + /** @var array{name: string, options: array{Namespace?: string, WorkflowID?: string|null, TaskQueueName?: string, StaticSummary?: string, StaticDetails?: string, ...}} $request */ + $request = $command->getOptions(); + $options = $request['options']; + $seq = $run->bind($command->getID(), RunState::CHILD); + $workflowId = ($options['WorkflowID'] ?? '') ?: $run->runId . '_' . $seq; + $run->startChild($command->getID(), $workflowId); + + return new WorkflowCommand([ + 'start_child_workflow_execution' => new StartChildWorkflowExecution([ + 'seq' => $seq, + 'namespace' => ($options['Namespace'] ?? '') ?: $this->namespace, + 'workflow_id' => $workflowId, + 'workflow_type' => $request['name'], + 'task_queue' => ($options['TaskQueueName'] ?? '') ?: $this->taskQueue, + 'input' => $this->payloads->payloads($command->getPayloads()), + 'workflow_execution_timeout' => ProtoTime::optionalDuration($options['WorkflowExecutionTimeout'] ?? 0), + 'workflow_run_timeout' => ProtoTime::optionalDuration($options['WorkflowRunTimeout'] ?? 0), + 'workflow_task_timeout' => ProtoTime::optionalDuration($options['WorkflowTaskTimeout'] ?? 0), + 'parent_close_policy' => (int) ($options['ParentClosePolicy'] ?? 0), + 'workflow_id_reuse_policy' => (int) ($options['WorkflowIDReusePolicy'] ?? 0), + 'retry_policy' => $this->retryPolicy($options['RetryPolicy'] ?? null), + 'cron_schedule' => (string) ($options['CronSchedule'] ?? ''), + 'headers' => $this->payloads->headerFields($command), + 'memo' => $this->payloads->collection((array) ($options['Memo'] ?? [])), + 'search_attributes' => isset($options['SearchAttributes']) + ? new SearchAttributes(['indexed_fields' => $this->payloads->collection((array) $options['SearchAttributes'])]) + : null, + 'cancellation_type' => ChildWorkflowCancellationType::WAIT_CANCELLATION_COMPLETED, + 'priority' => $this->priority($options['Priority'] ?? null), + ]), + 'user_metadata' => $this->userMetadata($options['StaticSummary'] ?? '', $options['StaticDetails'] ?? ''), + ]); + } + + /** + * @return list + */ + private function childExecution(RunState $run, RequestInterface $command, TickInfo $tick): array + { + /** @var array{id: int} $options */ + $options = $command->getOptions(); + $execution = $run->childExecution($options['id']); + if ($execution !== null) { + return [$this->resolutions->childExecution($command->getID(), $execution, $tick)]; + } + $run->awaitChildExecution($options['id'], $command->getID()); + + return []; + } + + private function signalExternal(RunState $run, RequestInterface $command): WorkflowCommand + { + /** @var array{namespace: string, workflowID: string, runID: ?string, signal: string, childWorkflowOnly: bool} $options */ + $options = $command->getOptions(); + $signal = new SignalExternalWorkflowExecution([ + 'seq' => $run->bind($command->getID(), RunState::SIGNAL_EXTERNAL), + 'signal_name' => $options['signal'], + 'args' => $this->payloads->payloads($command->getPayloads()), + 'headers' => $this->payloads->headerFields($command), + ]); + + if ($options['childWorkflowOnly'] ?? false) { + $signal->setChildWorkflowId($options['workflowID']); + } else { + $signal->setWorkflowExecution(new NamespacedWorkflowExecution([ + 'namespace' => ($options['namespace'] ?? '') ?: $this->namespace, + 'workflow_id' => $options['workflowID'], + 'run_id' => $options['runID'] ?? '', + ])); + } + + return new WorkflowCommand(['signal_external_workflow_execution' => $signal]); + } + + private function cancelExternal(RunState $run, RequestInterface $command): WorkflowCommand + { + /** @var array{namespace: string, workflowID: string, runID: ?string} $options */ + $options = $command->getOptions(); + + return new WorkflowCommand(['request_cancel_external_workflow_execution' => new RequestCancelExternalWorkflowExecution([ + 'seq' => $run->bind($command->getID(), RunState::CANCEL_EXTERNAL), + 'workflow_execution' => new NamespacedWorkflowExecution([ + 'namespace' => $options['namespace'] ?: $this->namespace, + 'workflow_id' => $options['workflowID'], + 'run_id' => $options['runID'] ?? '', + ]), + ])]); + } + + private function upsertSearchAttributes(RequestInterface $command): WorkflowCommand + { + /** @var array{searchAttributes: object} $options */ + $options = $command->getOptions(); + + return self::searchAttributes($this->payloads->collection((array) $options['searchAttributes'])); + } + + private function upsertTypedSearchAttributes(RequestInterface $command): WorkflowCommand + { + /** @var array{search_attributes: object} $options */ + $options = $command->getOptions(); + + return self::searchAttributes($this->typedSearchAttributePayloads((array) $options['search_attributes'])); + } + + private function upsertMemo(RequestInterface $command): WorkflowCommand + { + /** @var array{memo: object} $options */ + $options = $command->getOptions(); + + return new WorkflowCommand(['modify_workflow_properties' => new ModifyWorkflowProperties([ + 'upserted_memo' => new Memo(['fields' => $this->payloads->collection((array) $options['memo'])]), + ])]); + } + + private function panic(RequestInterface $command): WorkflowCommand + { + $panic = $command->getFailure() ?? new \RuntimeException($command->getOptions()['message'] ?? 'Workflow panic'); + if (!$this->failWorkflowOnPanic) { + throw $panic; + } + + return new WorkflowCommand(['fail_workflow_execution' => new FailWorkflowExecution([ + 'failure' => $this->payloads->failure($panic), + ])]); + } + + private function updateResponse(RunState $run, UpdateResponse $response): WorkflowCommand + { + /** @var array{id: int|string} $options */ + $options = $response->getOptions(); + $updateId = (string) $options['id']; + $result = new CoreUpdateResponse(['protocol_instance_id' => $run->updateProtocolInstanceId($updateId)]); + $failure = $response->getFailure(); + + match (true) { + $failure !== null => $result->setRejected($this->payloads->failure($failure)), + $response->getCommand() === UpdateResponse::COMMAND_VALIDATED => $result->setAccepted(new GPBEmpty()), + default => $result->setCompleted($this->payloads->firstPayload($response->getPayloads())), + }; + + if ($response->getCommand() === UpdateResponse::COMMAND_COMPLETED || $failure !== null) { + $run->finishUpdate($updateId); + } + + return new WorkflowCommand(['update_response' => $result]); + } + + /** + * @return array + */ + private function typedSearchAttributePayloads(array $attributes): array + { + $result = []; + foreach ($attributes as $name => $attribute) { + if ($attribute['operation'] !== self::SEARCH_ATTRIBUTE_SET) { + $result[$name] = $this->payloads->payload(null); + continue; + } + + $payload = $this->payloads->payload($attribute['value']); + /** @var \ArrayAccess $metadata */ + $metadata = $payload->getMetadata(); + $metadata['type'] = self::searchAttributeType(ValueType::from($attribute['type'])); + $result[$name] = $payload; + } + + return $result; + } + + private function userMetadata(string $summary, string $details = ''): ?UserMetadata + { + if (!$summary && !$details) { + return null; + } + + return new UserMetadata([ + 'summary' => $summary ? $this->payloads->payload($summary) : null, + 'details' => $details ? $this->payloads->payload($details) : null, + ]); + } + + private function priority(?array $priority): ?Priority + { + if ($priority === null) { + return null; + } + + return new Priority([ + 'priority_key' => (int) ($priority['priority_key'] ?? 0), + 'fairness_key' => (string) ($priority['fairness_key'] ?? ''), + 'fairness_weight' => (float) ($priority['fairness_weight'] ?? 0), + ]); + } + + private function retryPolicy(?array $retry): ?RetryPolicy + { + if ($retry === null) { + return null; + } + + return new RetryPolicy([ + 'initial_interval' => ProtoTime::optionalDuration($retry['initial_interval'] ?? null), + 'backoff_coefficient' => (float) ($retry['backoff_coefficient'] ?? 0), + 'maximum_interval' => ProtoTime::optionalDuration($retry['maximum_interval'] ?? null), + 'maximum_attempts' => (int) ($retry['maximum_attempts'] ?? 0), + 'non_retryable_error_types' => $retry['non_retryable_error_types'] ?? [], + ]); + } +} diff --git a/src/Worker/Core/CoreOptions.php b/src/Worker/Core/CoreOptions.php new file mode 100644 index 000000000..5ea6f6a2f --- /dev/null +++ b/src/Worker/Core/CoreOptions.php @@ -0,0 +1,126 @@ +apiKey ?? '') ?: $configProfile->apiKey; + $tls = $configProfile->tlsConfig; + + return new self( + address: $address ?? $configProfile->address ?? self::DEFAULT_ADDRESS, + namespace: $namespace ?? $configProfile->namespace ?? ClientOptions::DEFAULT_NAMESPACE, + apiKey: $apiKey === null ? null : (string) $apiKey, + tls: $tls === null || $tls->disabled ? null : $tls, + maxCachedWorkflows: CoreEnvironment::integer(CoreEnvironment::MAX_CACHED_WORKFLOWS, self::defaultMaxCachedWorkflows((string) \ini_get('memory_limit')), 0), + grpcCompression: CoreEnvironment::string(CoreEnvironment::GRPC_COMPRESSION) ?? self::DEFAULT_GRPC_COMPRESSION, + pollerAutoscaling: CoreEnvironment::flag(CoreEnvironment::POLLER_AUTOSCALING, true), + tuner: self::tuner(), + ); + } + + /** + * @psalm-suppress ArgumentTypeCoercion + */ + public static function defaultMaxCachedWorkflows(string $memoryLimit): int + { + $bytes = \ini_parse_quantity($memoryLimit); + if ($bytes <= 0) { + return self::DEFAULT_MAX_CACHED_WORKFLOWS; + } + + return \max( + self::MIN_DEFAULT_CACHED_WORKFLOWS, + \min(self::DEFAULT_MAX_CACHED_WORKFLOWS, \intdiv($bytes - self::MEMORY_RESERVE_BYTES, self::CACHED_WORKFLOW_BYTES)), + ); + } + + /** + * @return TunerShape|null + */ + private static function tuner(): ?array + { + $memory = CoreEnvironment::fraction(CoreEnvironment::TUNER_TARGET_MEMORY_USAGE); + $cpu = CoreEnvironment::fraction(CoreEnvironment::TUNER_TARGET_CPU_USAGE); + if ($memory === null && $cpu === null) { + return null; + } + if ($memory === null || $cpu === null) { + throw new \InvalidArgumentException(\sprintf('%s and %s must be set together', CoreEnvironment::TUNER_TARGET_MEMORY_USAGE, CoreEnvironment::TUNER_TARGET_CPU_USAGE)); + } + + return [ + 'target_memory_usage' => $memory, + 'target_cpu_usage' => $cpu, + 'workflow_slots' => self::slots(CoreEnvironment::TUNER_WORKFLOW_MIN_SLOTS, self::DEFAULT_WORKFLOW_MIN_SLOTS, CoreEnvironment::TUNER_WORKFLOW_MAX_SLOTS, CoreEnvironment::TUNER_WORKFLOW_RAMP_THROTTLE, self::DEFAULT_WORKFLOW_RAMP_THROTTLE_MS), + 'activity_slots' => self::slots(CoreEnvironment::TUNER_ACTIVITY_MIN_SLOTS, self::DEFAULT_ACTIVITY_MIN_SLOTS, CoreEnvironment::TUNER_ACTIVITY_MAX_SLOTS, CoreEnvironment::TUNER_ACTIVITY_RAMP_THROTTLE, self::DEFAULT_ACTIVITY_RAMP_THROTTLE_MS), + ]; + } + + /** + * @return SlotsShape + */ + private static function slots(string $min, int $defaultMin, string $max, string $rampThrottle, int $defaultRampThrottle): array + { + return [ + 'min_slots' => CoreEnvironment::integer($min, $defaultMin, 0), + 'max_slots' => CoreEnvironment::integer($max, self::DEFAULT_MAX_SLOTS, 1), + 'ramp_throttle_ms' => CoreEnvironment::integer($rampThrottle, $defaultRampThrottle, 0), + ]; + } +} diff --git a/src/Worker/Core/CoreReplayer.php b/src/Worker/Core/CoreReplayer.php new file mode 100644 index 000000000..17be63ee2 --- /dev/null +++ b/src/Worker/Core/CoreReplayer.php @@ -0,0 +1,103 @@ +bridge->newReplayer($config, $history->serializeToString(), $workflowId); + $tag = ++self::$tag; + try { + $failure = $this->drain($core, $tag, $activations); + } finally { + $errors = $this->bridge->shutdownWorkers([$tag => $core]); + } + + if ($failure !== null) { + throw $failure; + } + if ($errors !== []) { + throw new ReplayFailedException('The replay worker did not finalize: ' . \implode('; ', $errors), false); + } + } + + private static function evictionFailure(string $data): ?ReplayFailedException + { + $activation = new WorkflowActivation(); + $activation->mergeFromString($data); + $failure = null; + foreach ($activation->getJobs() as $job) { + $eviction = $job->getRemoveFromCache(); + if ($eviction !== null && !\in_array($eviction->getReason(), self::EXPECTED_EVICTIONS, true)) { + $failure = new ReplayFailedException($eviction->getMessage(), $eviction->getReason() === EvictionReason::NONDETERMINISM); + } + } + + return $failure; + } + + private function drain(\FFI\CData $core, int $tag, WorkflowActivations $activations): ?ReplayFailedException + { + $failure = null; + $deadline = \microtime(true) + $this->idleTimeoutSeconds; + $this->bridge->pollWorkflowActivation($core, $tag); + while (\microtime(true) < $deadline) { + foreach ($this->bridge->nextEvents(Bridge::POLL_TIMEOUT_MS) as [$eventTag, $kind, $status, $data]) { + if ($eventTag !== $tag) { + continue; + } + if ($kind === Bridge::KIND_WORKFLOW_COMPLETED) { + throw new \RuntimeException('sdk-core completion failed: ' . $data); + } + if ($kind !== Bridge::KIND_WORKFLOW_ACTIVATION) { + continue; + } + if ($status === Bridge::STATUS_SHUTDOWN) { + return $failure; + } + if ($status !== Bridge::STATUS_OK) { + throw new \RuntimeException('sdk-core poll failed: ' . $data); + } + + $failure = self::evictionFailure($data) ?? $failure; + $this->bridge->completeWorkflowActivation($core, $tag, $activations->handle($data)); + $deadline = \microtime(true) + $this->idleTimeoutSeconds; + $this->bridge->pollWorkflowActivation($core, $tag); + } + } + + return $failure ?? new ReplayFailedException(\sprintf('The replay got no activation for %g seconds', $this->idleTimeoutSeconds), false); + } +} diff --git a/src/Worker/Core/CoreRole.php b/src/Worker/Core/CoreRole.php new file mode 100644 index 000000000..b2f0acb45 --- /dev/null +++ b/src/Worker/Core/CoreRole.php @@ -0,0 +1,41 @@ + $marshaller + */ + public function __construct( + private readonly CoreOptions $options, + private readonly MarshallerInterface $marshaller, + ) {} + + /** + * @return array{workflows: bool, local_activities: bool, remote_activities: bool, ...} + * @psalm-suppress RedundantCondition, TypeDoesNotContainType, DeprecatedProperty + */ + public function build(WorkerInterface $worker, CoreRole $role): array + { + $options = $worker->getOptions(); + $workflows = self::runsWorkflows($worker, $role); + $cachedWorkflows = $workflows ? $this->options->maxCachedWorkflows : 0; + $minWorkflowTasks = $cachedWorkflows > 0 ? self::MIN_CACHED_WORKFLOW_TASKS : 1; + + return [ + 'namespace' => $this->options->namespace, + 'task_queue' => $worker->getID(), + 'workflows' => $workflows, + 'local_activities' => $workflows, + 'remote_activities' => self::runsRemoteActivities($worker, $role), + 'deployment' => isset($options->deploymentOptions) ? $this->marshaller->marshal($options->deploymentOptions) : null, + 'build_id' => $options->buildID, + 'graceful_shutdown_period_ms' => self::milliseconds($options->workerStopTimeout) ?? 0, + 'max_worker_activities_per_second' => $options->workerActivitiesPerSecond ?: null, + 'max_task_queue_activities_per_second' => $options->taskQueueActivitiesPerSecond ?: null, + 'max_cached_workflows' => $cachedWorkflows, + 'max_outstanding_workflow_tasks' => \max($minWorkflowTasks, $options->maxConcurrentWorkflowTaskExecutionSize ?: self::DEFAULT_MAX_OUTSTANDING_WORKFLOW_TASKS), + 'max_outstanding_activities' => self::SEQUENTIAL_ACTIVITIES, + 'max_outstanding_local_activities' => self::SEQUENTIAL_ACTIVITIES, + 'max_concurrent_workflow_task_polls' => \max($minWorkflowTasks, $options->maxConcurrentWorkflowTaskPollers ?: self::WORKFLOW_TASK_POLLERS), + 'nonsticky_to_sticky_poll_ratio' => self::NONSTICKY_TO_STICKY_POLL_RATIO, + 'sticky_queue_schedule_to_start_timeout_ms' => self::milliseconds($options->stickyScheduleToStartTimeout) ?? self::STICKY_SCHEDULE_TO_START_TIMEOUT_MS, + 'max_concurrent_activity_task_polls' => $options->maxConcurrentActivityTaskPollers ?: self::SEQUENTIAL_ACTIVITIES, + 'nondeterminism_fails_workflow' => $options->workflowPanicPolicy === WorkflowPanicPolicy::FailWorkflow, + 'max_heartbeat_throttle_interval_ms' => self::milliseconds($options->maxHeartbeatThrottleInterval), + 'poller_autoscaling' => $this->options->pollerAutoscaling, + 'tuner' => $this->tuner(), + ]; + } + + public function connection(WorkerInterface $worker, string $apiKey): array + { + $identity = $worker->getOptions()->identity; + $tls = $this->options->tls; + + return BridgeConnection::client( + $this->options->address, + $tls === null ? null : BridgeConnection::tls($tls->rootCerts, $tls->serverName, $tls->certChain, $tls->privateKey), + ) + [ + 'client_name' => SdkVersion::SDK_NAME, + 'client_version' => SdkVersion::getSdkVersion(), + 'identity' => $identity ?: (string) \getmypid() . '@' . (string) \gethostname(), + 'api_key' => $apiKey === '' ? null : $apiKey, + 'grpc_compression' => $this->options->grpcCompression, + ]; + } + + private static function runsWorkflows(WorkerInterface $worker, CoreRole $role): bool + { + return $role->runsWorkflows() && !$worker->getOptions()->disableWorkflowWorker; + } + + private static function runsRemoteActivities(WorkerInterface $worker, CoreRole $role): bool + { + return $role->runsRemoteActivities() && !$worker->getOptions()->localActivityWorkerOnly; + } + + private static function milliseconds(?\DateInterval $interval): ?int + { + return $interval === null ? null : (int) CarbonInterval::instance($interval)->totalMilliseconds; + } + + private function tuner(): ?array + { + $tuner = $this->options->tuner; + if ($tuner === null) { + return null; + } + + $activityMaxSlots = \min($tuner['activity_slots']['max_slots'], self::SEQUENTIAL_ACTIVITIES); + $tuner['activity_slots']['max_slots'] = $activityMaxSlots; + $tuner['activity_slots']['min_slots'] = \min($tuner['activity_slots']['min_slots'], $activityMaxSlots); + + return $tuner; + } +} diff --git a/src/Worker/Core/CoreWorkerFactory.php b/src/Worker/Core/CoreWorkerFactory.php new file mode 100644 index 000000000..35b5aa866 --- /dev/null +++ b/src/Worker/Core/CoreWorkerFactory.php @@ -0,0 +1,195 @@ +, string): void */ + private ?\Closure $wire = null; + + public static function create( + ?DataConverterInterface $converter = null, + ?RPCConnectionInterface $rpc = null, + ?ServiceCredentials $credentials = null, + ?PluginRegistry $pluginRegistry = null, + ?WorkflowClient $client = null, + ?string $address = null, + ?string $namespace = null, + ?LoggerInterface $logger = null, + ): static { + $converter ??= DataConverter::createDefault(); + $rpc ??= new ActivityTasks(); + if (!$rpc instanceof ActivityTasks) { + throw new \InvalidArgumentException(\sprintf('The sdk-core transport needs %s as the RPC connection', ActivityTasks::class)); + } + /** @psalm-suppress UnsafeInstantiation */ + $factory = new static($converter, $rpc, $credentials, $pluginRegistry, $client); + $factory->activityTasks = $rpc; + $factory->options = CoreOptions::create($address, $namespace, $credentials); + $factory->config = new CoreWorkerConfig($factory->options, $factory->marshaller); + $factory->logger = $logger ?? new StderrLogger(); + $factory->apiKey = $factory->options->apiKey ?? ''; + + return $factory; + } + + public function updateApiKey(\Stringable|string $key): void + { + $this->apiKey = $key; + } + + public function run(?HostConnectionInterface $host = null): int + { + if ($host !== null) { + throw new \InvalidArgumentException('The sdk-core transport does not use a host connection'); + } + + $role = CoreRole::fromEnvironment(); + + return $this->serve($role ?? CoreRole::All, $role !== null); + } + + /** + * @param \Closure(class-string, string): void $wire + * @internal + */ + public function observeWire(\Closure $wire): void + { + $this->wire = $wire; + } + + public function replay(History $history, string $workflowId): void + { + $events = $history->getEvents(); + if (\count($events) === 0) { + throw new \InvalidArgumentException('The history has no events'); + } + $taskQueue = (string) $events[0]->getWorkflowExecutionStartedEventAttributes()?->getTaskQueue()?->getName(); + $worker = $this->findWorkerByTaskQueue($taskQueue); + + $bridge = Bridge::shared(); + $bridge->useLogger($this->logger); + (new CoreReplayer($bridge))->replay( + $history, + $workflowId, + ['nondeterminism_fails_workflow' => false] + $this->config->build($worker, CoreRole::Workflow), + $this->activations($worker, $this->dispatch(...)), + ); + } + + private function serve(CoreRole $role, bool $supervised): int + { + $bridge = Bridge::shared(); + $bridge->useLogger($this->logger); + $this->activityTasks->bind($bridge, $this->dispatch(...), $this->converter); + $loop = new CoreWorkerLoop( + $bridge, + $this->activityTasks, + $this->logger, + $supervised ? \posix_getppid() : null, + fn(): string => (string) $this->apiKey, + $this->wire, + ); + + return Pipeline::prepare($this->pluginRegistry->getPlugins(WorkerPluginInterface::class)) + ->with(fn(): int => $loop->serve(fn(): array => $this->workerHandles($bridge, $role)), 'run')($this); + } + + /** + * @return list + */ + private function workerHandles(Bridge $bridge, CoreRole $role): array + { + $handles = []; + try { + foreach ($this->queues as $worker) { + $config = $this->config->build($worker, $role); + if ($config['workflows'] || $config['remote_activities']) { + $handles[] = new CoreWorkerHandle( + $bridge->newWorker(['connection' => $this->config->connection($worker, (string) $this->apiKey)] + $config), + (string) $worker->getID(), + $this->activations($worker, $this->dispatch(...)), + $config['workflows'], + $config['local_activities'] || $config['remote_activities'], + ); + } + } + } catch (\Throwable $e) { + $bridge->shutdownWorkers(\array_map(static fn(CoreWorkerHandle $handle): \FFI\CData => $handle->core, $handles)); + throw $e; + } + if ($handles === []) { + $this->logger->info(\sprintf('No task queue needs a %s process, exiting', $role->value)); + } + + return $handles; + } + + private function activations(WorkerInterface $worker, \Closure $dispatch): WorkflowActivations + { + $behaviors = []; + foreach ($worker->getWorkflows() as $workflow) { + $behaviors[$workflow->getID()] = $workflow->getVersioningBehavior()->value; + } + + return new WorkflowActivations( + $this->converter, + $dispatch, + $this->options->namespace, + (string) $worker->getID(), + $behaviors, + $worker->getOptions()->workflowPanicPolicy === WorkflowPanicPolicy::FailWorkflow, + ); + } + + /** + * @param list $commands + * @return list + */ + private function dispatch(array $commands, array $headers): array + { + /** @var list $commands */ + $this->dispatchCommands($commands, $headers); + + return \iterator_to_array($this->responses, false); + } +} diff --git a/src/Worker/Core/CoreWorkerHandle.php b/src/Worker/Core/CoreWorkerHandle.php new file mode 100644 index 000000000..728411b13 --- /dev/null +++ b/src/Worker/Core/CoreWorkerHandle.php @@ -0,0 +1,26 @@ + */ + private array $workers = []; + + private int $open = 0; + private bool $stopping = false; + private bool $shutdownRequested = false; + private bool $crashed = false; + private string $apiKeyInUse = ''; + + /** + * @param \Closure(): string $apiKey + * @param null|\Closure(class-string, string): void $wire + */ + public function __construct( + private readonly Bridge $bridge, + private readonly ActivityTasks $activityTasks, + private readonly LoggerInterface $logger, + private readonly ?int $supervisorPid, + private readonly \Closure $apiKey, + private readonly ?\Closure $wire = null, + ) {} + + /** + * @param \Closure(): list $workers + */ + public function serve(\Closure $workers): int + { + \pcntl_async_signals(true); + foreach (self::STOP_SIGNALS as $signal) { + \pcntl_signal($signal, $this->requestStop(...)); + } + try { + $this->apiKeyInUse = ($this->apiKey)(); + $this->workers = $workers(); + + return $this->workers === [] ? 0 : $this->run(); + } finally { + foreach (self::STOP_SIGNALS as $signal) { + \pcntl_signal($signal, \SIG_DFL); + } + } + } + + private function run(): int + { + $this->startPolling(); + if ($this->stopping) { + $this->initiateShutdown(); + } + + while ($this->open > 0) { + $this->checkSupervisor(); + $this->updateApiKey(); + $this->handle($this->bridge->nextEvents(Bridge::POLL_TIMEOUT_MS)); + } + + $cores = \array_map(static fn(CoreWorkerHandle $worker): \FFI\CData => $worker->core, $this->workers); + foreach ($this->bridge->shutdownWorkers($cores) as $tag => $error) { + $this->logger->error(\sprintf('sdk-core worker for task queue "%s" did not finalize: %s', $this->workers[$tag]->taskQueue, $error)); + } + + return $this->crashed ? 1 : 0; + } + + private function startPolling(): void + { + foreach ($this->workers as $tag => $worker) { + if ($worker->workflows) { + $this->bridge->pollWorkflowActivation($worker->core, $tag); + ++$this->open; + } + if ($worker->activities) { + $this->bridge->pollActivityTask($worker->core, $tag); + ++$this->open; + } + } + } + + private function requestStop(): void + { + $this->stopping = true; + $this->initiateShutdown(); + } + + private function initiateShutdown(): void + { + if ($this->shutdownRequested || $this->open === 0) { + return; + } + $this->shutdownRequested = true; + foreach ($this->workers as $worker) { + $this->bridge->initiateShutdown($worker->core); + } + } + + private function checkSupervisor(): void + { + if ($this->supervisorPid !== null && !$this->stopping && \posix_getppid() !== $this->supervisorPid) { + $this->logger->error('The supervisor process is gone, stopping'); + $this->requestStop(); + } + } + + private function updateApiKey(): void + { + $apiKey = ($this->apiKey)(); + if ($apiKey === $this->apiKeyInUse) { + return; + } + $this->apiKeyInUse = $apiKey; + foreach ($this->workers as $worker) { + $this->bridge->setApiKey($worker->core, $apiKey); + } + } + + /** + * @param list $events + */ + private function handle(array $events): void + { + foreach ($events as [$tag, $kind, $status, $data]) { + $worker = $this->workers[$tag]; + match ($kind) { + Bridge::KIND_WORKFLOW_ACTIVATION => $this->onActivation($worker, $tag, $status, $data), + Bridge::KIND_ACTIVITY_TASK => $this->onActivityTask($worker, $tag, $status, $data), + Bridge::KIND_WORKFLOW_COMPLETED, Bridge::KIND_ACTIVITY_COMPLETED => $this->logger->error('sdk-core completion failed: ' . $data), + default => null, + }; + } + } + + private function onActivation(CoreWorkerHandle $worker, int $tag, int $status, string $data): void + { + if ($status !== Bridge::STATUS_OK) { + $this->onPollFailure($status, $data); + return; + } + $this->wire?->__invoke(WorkflowActivation::class, $data); + $completion = $worker->activations->handle($data); + $this->wire?->__invoke(WorkflowActivationCompletion::class, $completion); + $this->bridge->completeWorkflowActivation($worker->core, $tag, $completion); + $this->bridge->pollWorkflowActivation($worker->core, $tag); + } + + private function onActivityTask(CoreWorkerHandle $worker, int $tag, int $status, string $data): void + { + if ($status !== Bridge::STATUS_OK) { + $this->onPollFailure($status, $data); + return; + } + $this->bridge->pollActivityTask($worker->core, $tag); + $this->wire?->__invoke(ActivityTask::class, $data); + $completion = $this->activityTasks->handle($worker->core, $worker->taskQueue, $data); + if ($completion !== null) { + $this->wire?->__invoke(ActivityTaskCompletion::class, $completion); + $this->bridge->completeActivityTask($worker->core, $tag, $completion); + } + } + + private function onPollFailure(int $status, string $error): void + { + --$this->open; + if ($this->stopping) { + return; + } + $this->logger->error($status === Bridge::STATUS_SHUTDOWN + ? 'sdk-core worker shut down unexpectedly, stopping the process' + : 'sdk-core poll failed, stopping the process: ' . $error); + $this->crashed = true; + $this->requestStop(); + } +} diff --git a/src/Worker/Core/InfoFactory.php b/src/Worker/Core/InfoFactory.php new file mode 100644 index 000000000..858d038af --- /dev/null +++ b/src/Worker/Core/InfoFactory.php @@ -0,0 +1,183 @@ +getParentWorkflowInfo(); + $root = $init->getRootWorkflow(); + + $info = (new \ReflectionClass(WorkflowInfo::class))->newInstanceWithoutConstructor(); + $info->execution = new WorkflowExecution($init->getWorkflowId(), $runId); + $info->type = new WorkflowType(); + $info->type->name = $init->getWorkflowType(); + $info->taskQueue = $taskQueue; + $info->executionTimeout = ProtoTime::interval($init->getWorkflowExecutionTimeout()); + $info->runTimeout = ProtoTime::interval($init->getWorkflowRunTimeout()); + $info->taskTimeout = ProtoTime::interval($init->getWorkflowTaskTimeout()); + $info->namespace = $namespace; + $info->attempt = $init->getAttempt(); + $info->cronSchedule = $init->getCronSchedule() ?: null; + $info->continuedExecutionRunId = $init->getContinuedFromExecutionRunId(); + $info->firstExecutionRunId = $init->getFirstExecutionRunId(); + $info->originalExecutionRunId = $init->getOriginalExecutionRunId() ?: $runId; + $info->parentNamespace = $parent?->getNamespace() ?? ''; + $info->parentExecution = self::execution($parent?->getWorkflowId(), $parent?->getRunId()); + $info->rootExecution = self::execution($root?->getWorkflowId(), $root?->getRunId()); + $info->typedSearchAttributes = TypedSearchAttributes::empty(); + if ($init->hasSearchAttributes()) { + /** @var SearchAttributes $searchAttributes */ + $searchAttributes = $init->getSearchAttributes(); + $info->searchAttributes = $this->payloads->decodeCollection($searchAttributes->getIndexedFields()); + $info->typedSearchAttributes = TypedSearchAttributes::fromJsonArray($this->typedSearchAttributes($searchAttributes)); + } + if ($init->hasMemo()) { + /** @var Memo $memo */ + $memo = $init->getMemo(); + $info->memo = $this->payloads->decodeCollection($memo->getFields()); + } + $info->retryOptions = self::retryOptions($init->getRetryPolicy()); + $info->priority = self::priorityOptions($init->getPriority()); + + return $info; + } + + /** + * @psalm-suppress InaccessibleProperty, ArgumentTypeCoercion + */ + public function activityInfo(string $token, Start $start, string $taskQueue): ActivityInfo + { + $scheduledTime = ProtoTime::micros($start->getScheduledTime()); + $startedTime = ProtoTime::micros($start->getStartedTime()); + + $info = (new \ReflectionClass(ActivityInfo::class))->newInstanceWithoutConstructor(); + $info->taskToken = $token; + $info->workflowType = new WorkflowType(); + $info->workflowType->name = $start->getWorkflowType(); + $info->workflowNamespace = $start->getWorkflowNamespace(); + $info->workflowExecution = new WorkflowExecution( + $start->getWorkflowExecution()?->getWorkflowId() ?? '', + $start->getWorkflowExecution()?->getRunId() ?? '', + ); + $info->id = $start->getActivityId(); + $info->type = new ActivityType(); + $info->type->name = $start->getActivityType(); + $info->taskQueue = $taskQueue; + $info->heartbeatTimeout = ProtoTime::interval($start->getHeartbeatTimeout()); + $info->scheduledTime = ProtoTime::utc($scheduledTime); + $info->startedTime = ProtoTime::utc($startedTime); + $info->deadline = ProtoTime::utc(self::deadline($start, $scheduledTime, $startedTime)); + $info->attempt = $start->getAttempt(); + $info->priority = self::priorityOptions($start->getPriority()); + $info->retryOptions = self::retryOptions($start->getRetryPolicy()); + + return $info; + } + + private static function deadline(Start $start, int $scheduledTime, int $startedTime): int + { + $scheduleToClose = ProtoTime::microsOf($start->getScheduleToCloseTimeout()); + $startToClose = ProtoTime::microsOf($start->getStartToCloseTimeout()); + + if ($scheduleToClose <= 0) { + return $startedTime + $startToClose; + } + if ($startToClose <= 0) { + return $scheduledTime + $scheduleToClose; + } + + return \min($scheduledTime + $scheduleToClose, $startedTime + $startToClose); + } + + /** + * @psalm-suppress InaccessibleProperty, PropertyTypeCoercion + */ + private static function retryOptions(?RetryPolicy $retry): ?RetryOptions + { + if ($retry === null) { + return null; + } + + $options = (new \ReflectionClass(RetryOptions::class))->newInstanceWithoutConstructor(); + $options->initialInterval = ProtoTime::interval($retry->getInitialInterval()); + $options->backoffCoefficient = $retry->getBackoffCoefficient(); + $options->maximumInterval = ProtoTime::interval($retry->getMaximumInterval()); + $options->maximumAttempts = $retry->getMaximumAttempts(); + $options->nonRetryableExceptions = \iterator_to_array($retry->getNonRetryableErrorTypes()); + + return $options; + } + + private static function priorityOptions(?Priority $priority): PriorityOptions + { + $options = PriorityOptions::new($priority?->getPriorityKey() ?? 0); + $options->fairnessKey = $priority?->getFairnessKey() ?? ''; + $options->fairnessWeight = self::float32AsJson($priority?->getFairnessWeight() ?? 0.0); + + return $options; + } + + private static function float32AsJson(float $value): float + { + return (float) \sprintf(self::FLOAT32_SIGNIFICANT_DIGITS, $value); + } + + /** + * @psalm-suppress ArgumentTypeCoercion + */ + private static function execution(?string $workflowId, ?string $runId): ?WorkflowExecution + { + return ($workflowId ?? '') ? new WorkflowExecution($workflowId, (string) $runId) : null; + } + + private function typedSearchAttributes(SearchAttributes $attributes): array + { + $result = []; + foreach ($attributes->getIndexedFields() as $name => $payload) { + $type = $payload->getMetadata()['type'] ?? null; + if ($type !== null) { + $result[$name] = ['type' => $type, 'value' => $this->payloads->decode($payload)]; + } + } + + return $result; + } +} diff --git a/src/Worker/Core/LocalActivityRetries.php b/src/Worker/Core/LocalActivityRetries.php new file mode 100644 index 000000000..faf0a07f0 --- /dev/null +++ b/src/Worker/Core/LocalActivityRetries.php @@ -0,0 +1,67 @@ + */ + private array $scheduled = []; + + /** @var array */ + private array $backoffs = []; + + public function schedule(ScheduleLocalActivity $activity): ScheduleLocalActivity + { + return $this->scheduled[$activity->getSeq()] = $activity; + } + + public function isBackoffTimer(int $timerSeq): bool + { + return isset($this->backoffs[$timerSeq]); + } + + public function backoff(int $seq, int $timerSeq, DoBackoff $backoff): WorkflowCommand + { + $activity = $this->scheduled[$seq]; + unset($this->scheduled[$seq]); + $activity->setAttempt($backoff->getAttempt()); + $activity->setOriginalScheduleTime($backoff->getOriginalScheduleTime()); + $this->backoffs[$timerSeq] = $activity; + + return new WorkflowCommand(['start_timer' => new StartTimer([ + 'seq' => $timerSeq, + 'start_to_fire_timeout' => $backoff->getBackoffDuration(), + ])]); + } + + public function retry(int $timerSeq, int $seq): WorkflowCommand + { + $activity = $this->backoffs[$timerSeq]; + unset($this->backoffs[$timerSeq]); + $activity->setSeq($seq); + + return new WorkflowCommand(['schedule_local_activity' => $this->schedule($activity)]); + } + + public function forget(int $seq): void + { + unset($this->scheduled[$seq], $this->backoffs[$seq]); + } +} diff --git a/src/Worker/Core/PatchVersions.php b/src/Worker/Core/PatchVersions.php new file mode 100644 index 000000000..b0f22b719 --- /dev/null +++ b/src/Worker/Core/PatchVersions.php @@ -0,0 +1,71 @@ + */ + private array $notified = []; + + /** @var array */ + private array $versions = []; + + public function notify(string $patchId): void + { + if (\preg_match(self::PATCH_ID, $patchId, $match) === 1) { + $this->notified[$match[1]] = (int) $match[2]; + } + } + + /** + * @param list $commands + */ + public function version(string $changeId, int $minSupported, int $maxSupported, bool $isReplaying, array &$commands): int + { + if (!isset($this->versions[$changeId])) { + $version = $this->notified[$changeId] ?? ($isReplaying ? self::DEFAULT_VERSION : $maxSupported); + $this->versions[$changeId] = $version; + if ($version !== self::DEFAULT_VERSION) { + $commands[] = new WorkflowCommand(['set_patch_marker' => new SetPatchMarker(['patch_id' => $changeId . '-' . $version])]); + } + } + + $version = $this->versions[$changeId]; + if ($version < $minSupported) { + throw new \LogicException(\sprintf( + 'Workflow code removed support of version %d for "%s" changeID. The oldest supported version is %d', + $version, + $changeId, + $minSupported, + )); + } + if ($version > $maxSupported) { + throw new \LogicException(\sprintf( + 'Workflow code is too old to support version %d for "%s" changeID. The maximum supported version is %d', + $version, + $changeId, + $maxSupported, + )); + } + + return $version; + } +} diff --git a/src/Worker/Core/PayloadMapper.php b/src/Worker/Core/PayloadMapper.php new file mode 100644 index 000000000..d8492115a --- /dev/null +++ b/src/Worker/Core/PayloadMapper.php @@ -0,0 +1,148 @@ +&\ArrayAccess&\Countable $payloads + */ + public function values(\Traversable $payloads): ValuesInterface + { + return EncodedValues::fromPayloadCollection($payloads, $this->converter); + } + + /** + * @param list $values + */ + public function encode(array $values): ValuesInterface + { + return EncodedValues::fromValues($values, $this->converter); + } + + public function valuesFromPayload(?Payload $payload): ?ValuesInterface + { + return $payload === null ? null : $this->values(new \ArrayIterator([$payload])); + } + + /** + * @param \Traversable&\ArrayAccess&\Countable $fields + */ + public function header(\Traversable $fields): Header + { + return Header::fromPayloadCollection($fields, $this->converter); + } + + /** + * @return array + */ + public function headerFields(RequestInterface $command): array + { + $header = $command->getHeader(); + \assert($header instanceof Header); + $header->setDataConverter($this->converter); + + return \iterator_to_array($header->toHeader()->getFields()); + } + + public function payloads(ValuesInterface $values): RepeatedField + { + $values->setDataConverter($this->converter); + + return $values->toPayloads()->getPayloads(); + } + + public function firstPayload(?ValuesInterface $values): ?Payload + { + if ($values === null) { + return null; + } + + /** @var \Traversable&\ArrayAccess&\Countable $payloads */ + $payloads = $this->payloads($values); + + return \count($payloads) > 0 ? $payloads[0] : null; + } + + public function payload(mixed $value): Payload + { + return $this->converter->toPayload($value); + } + + /** + * @return array + */ + public function collection(array $values): array + { + return EncodedCollection::fromValues($values, $this->converter)->toPayloadArray(); + } + + /** + * @param \Traversable&\ArrayAccess&\Countable $payloads + */ + public function decodeCollection(\ArrayAccess $payloads): array + { + return EncodedCollection::fromPayloadCollection($payloads, $this->converter)->getValues(); + } + + public function decode(Payload $payload): mixed + { + return $this->converter->fromPayload($payload, null); + } + + public function failure(\Throwable $error): Failure + { + try { + return FailureConverter::mapExceptionToFailure($error, $this->converter); + } catch (\Exception $e) { + if (!self::isInvalidUtf8($e)) { + throw $e; + } + + return new Failure([ + 'message' => \mb_scrub($error->getMessage(), 'UTF-8'), + 'source' => 'PHP_SDK', + 'stack_trace' => \mb_scrub($error->getTraceAsString(), 'UTF-8'), + 'application_failure_info' => new ApplicationFailureInfo(['type' => $error::class]), + ]); + } + } + + public function exception(Failure $failure): \Throwable + { + return FailureConverter::mapFailureToException($failure, $this->converter); + } + + private static function isInvalidUtf8(\Exception $error): bool + { + return $error::class === \Exception::class && \stripos($error->getMessage(), 'utf-8') !== false; + } +} diff --git a/src/Worker/Core/ProtoTime.php b/src/Worker/Core/ProtoTime.php new file mode 100644 index 000000000..4afff31ae --- /dev/null +++ b/src/Worker/Core/ProtoTime.php @@ -0,0 +1,101 @@ + */ + private static array $intervals = []; + + public static function duration(int $nanos): Duration + { + return new Duration(['seconds' => \intdiv($nanos, self::NANOS_PER_SECOND), 'nanos' => $nanos % self::NANOS_PER_SECOND]); + } + + public static function optionalDuration(int|array|null $value): ?Duration + { + if (\is_array($value)) { + return new Duration(['seconds' => (int) ($value['seconds'] ?? 0), 'nanos' => (int) ($value['nanos'] ?? 0)]); + } + + return $value > 0 ? self::duration($value) : null; + } + + public static function interval(?Duration $duration): CarbonInterval + { + $nanos = self::nanos($duration); + if (!isset(self::$intervals[$nanos])) { + if (\count(self::$intervals) >= self::INTERVAL_CACHE_SIZE) { + self::$intervals = []; + } + /** @psalm-suppress TypeDoesNotContainType, TypeDoesNotContainNull, RedundantCondition, PossiblyNullArrayOffset */ + self::$intervals[$nanos] = DateInterval::parse($nanos, DateInterval::FORMAT_NANOSECONDS); + } + + return clone self::$intervals[$nanos]; + } + + public static function timestamp(\DateTimeInterface $time): Timestamp + { + $timestamp = new Timestamp(); + $timestamp->fromDateTime(\DateTime::createFromInterface($time)); + + return $timestamp; + } + + public static function dateTime(?Timestamp $timestamp, \DateTimeZone $zone): \DateTimeImmutable + { + $time = $timestamp === null ? new \DateTimeImmutable() : \DateTimeImmutable::createFromInterface($timestamp->toDateTime()); + + return $time->setTimezone($zone); + } + + public static function micros(?Timestamp $timestamp): int + { + return $timestamp === null + ? (int) (new \DateTimeImmutable())->format('Uu') + : self::microsOf($timestamp); + } + + public static function microsOf(Timestamp|Duration|null $value): int + { + return $value === null ? 0 : (int) $value->getSeconds() * self::MICROS_PER_SECOND + \intdiv($value->getNanos(), self::NANOS_PER_MICRO); + } + + public static function utc(int $micros): Carbon + { + return new Carbon( + \gmdate('Y-m-d\TH:i:s', \intdiv($micros, self::MICROS_PER_SECOND)) + . \sprintf('.%06d+00:00', $micros % self::MICROS_PER_SECOND), + ); + } + + private static function nanos(?Duration $duration): int + { + return $duration === null ? 0 : (int) $duration->getSeconds() * self::NANOS_PER_SECOND + $duration->getNanos(); + } +} diff --git a/src/Worker/Core/ReplayFailedException.php b/src/Worker/Core/ReplayFailedException.php new file mode 100644 index 000000000..c312366aa --- /dev/null +++ b/src/Worker/Core/ReplayFailedException.php @@ -0,0 +1,25 @@ +release($seq); + $tryCancel = $run->takeTryCancel($seq); + $run->localActivities->forget($seq); + + return match ($result->getStatus()) { + 'completed' => new SuccessResponse($this->payloads->valuesFromPayload($result->getCompleted()->getResult()), $requestId, $tick), + 'failed' => new FailureResponse($this->payloads->exception($result->getFailed()->getFailure()), $requestId, $tick), + 'cancelled' => new FailureResponse($this->cancelledActivity($result->getCancelled()->getFailure(), $tryCancel), $requestId, $tick), + }; + } + + /** + * @return list + * @psalm-suppress PossiblyNullReference, PossiblyNullArgument + */ + public function childStarted(RunState $run, ResolveChildWorkflowExecutionStart $start, TickInfo $tick): array + { + $childId = $run->requestId($start->getSeq()); + $messages = []; + + switch ($start->getStatus()) { + case 'succeeded': + $execution = $run->childStarted($childId, $start->getSucceeded()->getRunId()); + break; + case 'failed': + $failed = $start->getFailed(); + $run->release($start->getSeq()); + $execution = $this->childAlreadyStarted($failed->getWorkflowType(), $failed->getWorkflowId()); + $messages[] = new FailureResponse($execution, $childId, $tick); + break; + default: + $run->release($start->getSeq()); + $execution = $this->payloads->exception($start->getCancelled()->getFailure()); + $messages[] = new FailureResponse($execution, $childId, $tick); + break; + } + + foreach ($run->takeChildWaiters($childId) as $waiter) { + $messages[] = $this->childExecution($waiter, $execution, $tick); + } + if ($execution instanceof \Throwable) { + $run->forgetChild($childId); + } + + return $messages; + } + + /** + * @param array{string, string}|\Throwable $execution + * @psalm-suppress ArgumentTypeCoercion + */ + public function childExecution(int $requestId, array|\Throwable $execution, TickInfo $tick): CommandInterface + { + if ($execution instanceof \Throwable) { + return new FailureResponse($execution, $requestId, $tick); + } + + return new SuccessResponse($this->payloads->encode([new WorkflowExecution($execution[0], $execution[1])]), $requestId, $tick); + } + + /** + * @psalm-suppress PossiblyNullReference, PossiblyNullArgument + */ + public function childResult(RunState $run, ResolveChildWorkflowExecution $resolve, TickInfo $tick): CommandInterface + { + $requestId = $run->release($resolve->getSeq()); + $run->forgetChild($requestId); + $result = $resolve->getResult(); + + return match ($result->getStatus()) { + 'completed' => new SuccessResponse($this->payloads->valuesFromPayload($result->getCompleted()->getResult()), $requestId, $tick), + 'failed' => new FailureResponse($this->payloads->exception($result->getFailed()->getFailure()), $requestId, $tick), + default => new FailureResponse($this->payloads->exception($result->getCancelled()->getFailure()), $requestId, $tick), + }; + } + + public function external(int $requestId, ?Failure $failure, TickInfo $tick): CommandInterface + { + return $failure === null + ? new SuccessResponse(null, $requestId, $tick) + : new FailureResponse($this->payloads->exception($failure), $requestId, $tick); + } + + /** + * @psalm-suppress ArgumentTypeCoercion + */ + private function childAlreadyStarted(string $workflowType, string $workflowId): ChildWorkflowFailure + { + return new ChildWorkflowFailure( + initiatedEventId: self::UNKNOWN_EVENT_ID, + startedEventId: self::UNKNOWN_EVENT_ID, + workflowType: $workflowType, + execution: new WorkflowExecution($workflowId), + namespace: $this->namespace, + retryState: RetryState::RETRY_STATE_UNSPECIFIED, + previous: new ApplicationFailure(self::CHILD_ALREADY_STARTED_MESSAGE, self::CHILD_ALREADY_STARTED_TYPE, true), + ); + } + + private function cancelledActivity(Failure $failure, bool $tryCancel): \Throwable + { + $error = $this->payloads->exception($failure); + + return $tryCancel && $error->getPrevious() instanceof CanceledFailure ? $error->getPrevious() : $error; + } +} diff --git a/src/Worker/Core/RunState.php b/src/Worker/Core/RunState.php new file mode 100644 index 000000000..c1095b070 --- /dev/null +++ b/src/Worker/Core/RunState.php @@ -0,0 +1,174 @@ + */ + private array $tryCancelActivities = []; + + /** @var array */ + private array $updates = []; + + /** @var array */ + private array $childWorkflowIds = []; + + /** @var array */ + private array $childExecutions = []; + + /** @var array> */ + private array $childWaiters = []; + + /** @var array */ + private array $requests = []; + + /** @var array */ + private array $commands = []; + + private int $seq = 0; + + /** + * @param non-empty-string $runId + */ + public function __construct( + public readonly string $runId, + ) { + $this->patches = new PatchVersions(); + $this->localActivities = new LocalActivityRetries(); + } + + public function bind(int $requestId, int $type): int + { + $seq = ++$this->seq; + $this->requests[$seq] = $requestId; + $this->commands[$requestId] = [$type, $seq]; + + return $seq; + } + + public function rebind(int $seq, int $type): int + { + return $this->bind($this->release($seq), $type); + } + + public function requestId(int $seq): int + { + if (!isset($this->requests[$seq])) { + throw new \OutOfBoundsException("Unknown command sequence $seq in run {$this->runId}"); + } + + return $this->requests[$seq]; + } + + public function release(int $seq): int + { + $requestId = $this->requestId($seq); + unset($this->requests[$seq], $this->commands[$requestId]); + + return $requestId; + } + + /** + * @return array{int, int}|null + */ + public function command(int $requestId): ?array + { + return $this->commands[$requestId] ?? null; + } + + public function markTryCancel(int $seq): void + { + $this->tryCancelActivities[$seq] = true; + } + + public function takeTryCancel(int $seq): bool + { + $tryCancel = isset($this->tryCancelActivities[$seq]); + unset($this->tryCancelActivities[$seq]); + + return $tryCancel; + } + + public function startUpdate(string $updateId, string $protocolInstanceId): void + { + $this->updates[$updateId] = $protocolInstanceId; + } + + public function updateProtocolInstanceId(string $updateId): string + { + return $this->updates[$updateId] ?? $updateId; + } + + public function finishUpdate(string $updateId): void + { + unset($this->updates[$updateId]); + } + + public function startChild(int $requestId, string $workflowId): void + { + $this->childWorkflowIds[$requestId] = $workflowId; + } + + /** + * @return array{string, string} + */ + public function childStarted(int $requestId, string $runId): array + { + $execution = [$this->childWorkflowIds[$requestId], $runId]; + unset($this->childWorkflowIds[$requestId]); + + return $this->childExecutions[$requestId] = $execution; + } + + /** + * @return array{string, string}|null + */ + public function childExecution(int $requestId): ?array + { + return $this->childExecutions[$requestId] ?? null; + } + + public function awaitChildExecution(int $requestId, int $waiterId): void + { + $this->childWaiters[$requestId][] = $waiterId; + } + + /** + * @return list + */ + public function takeChildWaiters(int $requestId): array + { + $waiters = $this->childWaiters[$requestId] ?? []; + unset($this->childWaiters[$requestId]); + + return $waiters; + } + + public function forgetChild(int $requestId): void + { + unset($this->childWorkflowIds[$requestId], $this->childExecutions[$requestId]); + } +} diff --git a/src/Worker/Core/WorkflowActivations.php b/src/Worker/Core/WorkflowActivations.php new file mode 100644 index 000000000..91f279611 --- /dev/null +++ b/src/Worker/Core/WorkflowActivations.php @@ -0,0 +1,282 @@ + */ + private array $runs = []; + + private readonly \DateTimeZone $timeZone; + private readonly PayloadMapper $payloads; + private readonly InfoFactory $info; + private readonly ResolutionMapper $resolutions; + private readonly CommandTranslator $translator; + + /** @var array */ + private readonly array $headers; + + /** + * @param \Closure(list, array): list $dispatch + * @param array $versioningBehaviors + */ + public function __construct( + DataConverterInterface $converter, + private readonly \Closure $dispatch, + private readonly string $namespace, + private readonly string $taskQueue, + private readonly array $versioningBehaviors, + bool $failWorkflowOnPanic, + ) { + $this->timeZone = new \DateTimeZone(\date_default_timezone_get()); + $this->payloads = new PayloadMapper($converter); + $this->info = new InfoFactory($this->payloads); + $this->resolutions = new ResolutionMapper($this->payloads, $namespace); + $this->translator = new CommandTranslator($this->payloads, $this->resolutions, $namespace, $taskQueue, $failWorkflowOnPanic); + $this->headers = ['taskQueue' => $taskQueue]; + } + + public function handle(string $bytes): string + { + $activation = new WorkflowActivation(); + $activation->mergeFromString($bytes); + $completion = new WorkflowActivationCompletion(['run_id' => $activation->getRunId()]); + + try { + $commands = $this->process($activation); + $completion->setSuccessful(new CompletionSuccess([ + 'commands' => $commands, + 'versioning_behavior' => $this->runs[$activation->getRunId()]->versioningBehavior ?? 0, + ])); + } catch (\Throwable $e) { + $completion->setFailed(new CompletionFailure([ + 'failure' => $this->payloads->failure($e), + ])); + } + + return $completion->serializeToString(); + } + + /** + * @return list + * @psalm-suppress PossiblyNullReference, PossiblyNullArgument, ArgumentTypeCoercion + */ + private function process(WorkflowActivation $activation): array + { + $runId = $activation->getRunId(); + $tick = new TickInfo( + time: ProtoTime::dateTime($activation->getTimestamp(), $this->timeZone), + historyLength: $activation->getHistoryLength(), + historySize: (int) $activation->getHistorySizeBytes(), + continueAsNewSuggested: $activation->getContinueAsNewSuggested(), + isReplaying: $activation->getIsReplaying(), + ); + $run = $this->runs[$runId] ??= new RunState($runId); + + $messages = []; + $queries = []; + $commands = []; + foreach ($activation->getJobs() as $job) { + switch ($job->getVariant()) { + case 'initialize_workflow': + $run->versioningBehavior = $this->versioningBehaviors[$job->getInitializeWorkflow()->getWorkflowType()] ?? 0; + $messages[] = $this->startWorkflow($job->getInitializeWorkflow(), $runId, $tick); + break; + case 'fire_timer': + $seq = $job->getFireTimer()->getSeq(); + if ($run->localActivities->isBackoffTimer($seq)) { + $commands[] = $run->localActivities->retry($seq, $run->rebind($seq, RunState::LOCAL_ACTIVITY)); + break; + } + $messages[] = new SuccessResponse(null, $run->release($seq), $tick); + break; + case 'resolve_activity': + $resolve = $job->getResolveActivity(); + $seq = $resolve->getSeq(); + if ($resolve->getResult()->getStatus() === 'backoff') { + $commands[] = $run->localActivities->backoff($seq, $run->rebind($seq, RunState::TIMER), $resolve->getResult()->getBackoff()); + break; + } + $messages[] = $this->resolutions->activity($run, $seq, $resolve->getResult(), $tick); + break; + case 'signal_workflow': + $signal = $job->getSignalWorkflow(); + $messages[] = new ServerRequest( + name: 'InvokeSignal', + info: $tick, + options: ['runId' => $runId, 'name' => $signal->getSignalName()], + payloads: $this->payloads->values($signal->getInput()), + id: $runId, + header: $this->payloads->header($signal->getHeaders()), + ); + break; + case 'query_workflow': + $queries[] = $job->getQueryWorkflow(); + break; + case 'cancel_workflow': + $messages[] = new ServerRequest('CancelWorkflow', $tick, ['runId' => $runId], id: $runId); + break; + case 'do_update': + $messages[] = $this->update($run, $job->getDoUpdate(), $tick); + break; + case 'notify_has_patch': + $run->patches->notify($job->getNotifyHasPatch()->getPatchId()); + break; + case 'resolve_child_workflow_execution_start': + \array_push($messages, ...$this->resolutions->childStarted($run, $job->getResolveChildWorkflowExecutionStart(), $tick)); + break; + case 'resolve_child_workflow_execution': + $messages[] = $this->resolutions->childResult($run, $job->getResolveChildWorkflowExecution(), $tick); + break; + case 'resolve_signal_external_workflow': + $resolve = $job->getResolveSignalExternalWorkflow(); + $messages[] = $this->resolutions->external($run->release($resolve->getSeq()), $resolve->getFailure(), $tick); + break; + case 'resolve_request_cancel_external_workflow': + $resolve = $job->getResolveRequestCancelExternalWorkflow(); + $messages[] = $this->resolutions->external($run->release($resolve->getSeq()), $resolve->getFailure(), $tick); + break; + case 'remove_from_cache': + unset($this->runs[$runId]); + ($this->dispatch)([new ServerRequest('DestroyWorkflow', $tick, ['runId' => $runId], id: $runId)], $this->headers); + return []; + case 'update_random_seed': + break; + default: + throw new \LogicException(\sprintf('Unsupported activation job "%s"', $job->getVariant())); + } + } + + $this->exchange($run, $messages, $tick, $commands); + + if ($queries !== []) { + $queryTick = new TickInfo($tick->time, $tick->historyLength, $tick->historySize, $tick->continueAsNewSuggested); + foreach ($queries as $query) { + $commands[] = $this->query($run, $query, $queryTick); + } + } + + return $commands; + } + + /** + * @param list $messages + * @param list $commands + */ + private function exchange(RunState $run, array $messages, TickInfo $tick, array &$commands): void + { + while ($messages !== []) { + $outgoing = ($this->dispatch)($messages, $this->headers); + $messages = []; + foreach ($outgoing as $command) { + \array_push($messages, ...$this->translator->translate($run, $command, $tick, $commands)); + } + } + } + + private function query(RunState $run, QueryWorkflow $query, TickInfo $tick): WorkflowCommand + { + $request = new ServerRequest( + name: 'InvokeQuery', + info: $tick, + options: ['runId' => $run->runId, 'name' => $query->getQueryType()], + payloads: $this->payloads->values($query->getArguments()), + id: $run->runId, + header: $this->payloads->header($query->getHeaders()), + ); + + $result = new QueryResult(['query_id' => $query->getQueryId()]); + foreach (($this->dispatch)([$request], $this->headers) as $response) { + if ($response instanceof SuccessClientResponse) { + $result->setSucceeded(new QuerySuccess(['response' => $this->payloads->firstPayload($response->getPayloads())])); + } elseif ($response instanceof FailedClientResponse) { + $result->setFailed($this->payloads->failure($response->getFailure())); + } + } + + if ($result->getVariant() === '') { + $result->setFailed($this->payloads->failure(new \LogicException('Query produced no result'))); + } + + return new WorkflowCommand(['respond_to_query' => $result]); + } + + private function update(RunState $run, DoUpdate $update, TickInfo $tick): ServerRequest + { + $run->startUpdate($update->getId(), $update->getProtocolInstanceId()); + + return new ServerRequest( + name: 'InvokeUpdate', + info: $tick, + options: [ + 'runId' => $run->runId, + 'updateId' => $update->getId(), + 'name' => $update->getName(), + 'replay' => !$update->getRunValidator(), + ], + payloads: $this->payloads->values($update->getInput()), + id: $run->runId, + header: $this->payloads->header($update->getHeaders()), + ); + } + + /** + * @param non-empty-string $runId + */ + private function startWorkflow(InitializeWorkflow $init, string $runId, TickInfo $tick): ServerRequest + { + /** @var \Traversable&\ArrayAccess&\Countable $arguments */ + $arguments = $init->getArguments(); + $payloads = $this->payloads->values($arguments); + $options = ['info' => $this->info->workflowInfo($init, $runId, $this->namespace, $this->taskQueue)]; + + /** @var (\Traversable&\Countable)|null $lastCompletion */ + $lastCompletion = $init->getLastCompletionResult()?->getPayloads(); + if ($lastCompletion !== null && \count($lastCompletion) > 0) { + $all = new Payloads(); + $all->setPayloads([...$arguments, ...$lastCompletion]); + $payloads = $this->payloads->values($all->getPayloads()); + $options['lastCompletion'] = \count($lastCompletion); + } + + return new ServerRequest( + name: 'StartWorkflow', + info: $tick, + options: $options, + payloads: $payloads, + id: $runId, + header: $this->payloads->header($init->getHeaders()), + ); + } +} diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php index c64245815..7d4d713d9 100644 --- a/src/WorkerFactory.php +++ b/src/WorkerFactory.php @@ -190,7 +190,7 @@ public function newWorker( $interceptorProvider ?? new SimplePipelineProvider(), ); - $worker = new Worker( + $worker = $this->createWorker( $taskQueue, $options, ServiceContainer::fromWorkerFactory( @@ -203,7 +203,6 @@ public function newWorker( $taskQueue, ), ), - $this->rpc, ); // Call initializeWorker hooks (forward order) @@ -338,6 +337,41 @@ protected function createMarshaller(ReaderInterface $reader): MarshallerInterfac return new Marshaller(new AttributeMapperFactory($reader)); } + protected function createWorker(string $taskQueue, WorkerOptions $options, ServiceContainer $services): WorkerInterface + { + return new Worker($taskQueue, $options, $services, $this->rpc); + } + + /** + * @param iterable $commands + */ + protected function dispatchCommands(iterable $commands, array $headers): void + { + foreach ($commands as $command) { + $this->env->update($command->getTickInfo()); + + if ($command instanceof ServerResponseInterface) { + $this->client->dispatch($command); + continue; + } + + $this->server->dispatch($command, $headers); + } + + $this->tick(); + } + + protected function findWorkerByTaskQueue(string $taskQueue): WorkerInterface + { + $worker = $this->queues->find($taskQueue); + + if ($worker === null) { + throw new \OutOfRangeException(\sprintf(self::ERROR_QUEUE_NOT_FOUND, $taskQueue)); + } + + return $worker; + } + private function boot(ServiceCredentials $credentials): void { $this->reader = $this->createReader(); @@ -366,21 +400,7 @@ private function createCodec(): CodecInterface */ private function dispatch(string $messages, array $headers): string { - $commands = $this->codec->decode($messages, $headers); - - - foreach ($commands as $command) { - $this->env->update($command->getTickInfo()); - - if ($command instanceof ServerResponseInterface) { - $this->client->dispatch($command); - continue; - } - - $this->server->dispatch($command, $headers); - } - - $this->tick(); + $this->dispatchCommands($this->codec->decode($messages, $headers), $headers); return $this->codec->encode($this->responses); } @@ -398,17 +418,6 @@ private function onRequest(ServerRequestInterface $request, array $headers): Pro return $worker->dispatch($request, $headers); } - private function findWorkerByTaskQueue(string $taskQueue): WorkerInterface - { - $worker = $this->queues->find($taskQueue); - - if ($worker === null) { - throw new \OutOfRangeException(\sprintf(self::ERROR_QUEUE_NOT_FOUND, $taskQueue)); - } - - return $worker; - } - private function findTaskQueueNameOrFail(array $headers): string { $taskQueue = $headers[self::HEADER_TASK_QUEUE]; diff --git a/testing/src/CoreWorkerFactory.php b/testing/src/CoreWorkerFactory.php new file mode 100644 index 000000000..3dad58bbd --- /dev/null +++ b/testing/src/CoreWorkerFactory.php @@ -0,0 +1,22 @@ +rpc), + RoadRunnerActivityInvocationCache::create($this->converter), + ); + } +} diff --git a/testing/src/Interactions/WorkflowInteractions.php b/testing/src/Interactions/WorkflowInteractions.php index 4480a101c..3cc7806df 100644 --- a/testing/src/Interactions/WorkflowInteractions.php +++ b/testing/src/Interactions/WorkflowInteractions.php @@ -7,6 +7,7 @@ use Google\Protobuf\Duration; use PHPUnit\Framework\Assert; use Temporal\Api\Enums\V1\EventType; +use Temporal\Worker\Core\ActivityTasks; use Temporal\Api\History\V1\HistoryEvent; use Temporal\Api\History\V1\MarkerRecordedEventAttributes; use Temporal\Client\WorkflowClient; @@ -17,8 +18,10 @@ final class WorkflowInteractions { private const MARKER_LOCAL_ACTIVITY = 'LocalActivity'; + private const MARKER_CORE_LOCAL_ACTIVITY = 'core_local_activity'; private const MARKER_DETAIL_DATA = 'data'; private const MARKER_ACTIVITY_TYPE_KEY = 'ActivityType'; + private const MARKER_CORE_ACTIVITY_TYPE_KEY = 'activity_type'; /** * @param list $calls @@ -101,6 +104,26 @@ public function assertNoOtherActivities(): void ); } + private static function localActivityType(MarkerRecordedEventAttributes $attributes): string + { + foreach ($attributes->getDetails() as $key => $payloads) { + if ($key !== self::MARKER_DETAIL_DATA) { + continue; + } + + $items = $payloads->getPayloads(); + if (\count($items) === 0) { + return ''; + } + + $decoded = \json_decode($items[0]->getData(), true); + + return \is_array($decoded) ? (string) ($decoded[self::MARKER_ACTIVITY_TYPE_KEY] ?? $decoded[self::MARKER_CORE_ACTIVITY_TYPE_KEY] ?? '') : ''; + } + + return ''; + } + private static function decode(HistoryEvent $event): ?RecordedCall { switch ($event->getEventType()) { @@ -138,40 +161,19 @@ private static function decode(HistoryEvent $event): ?RecordedCall ); case EventType::EVENT_TYPE_MARKER_RECORDED: $attributes = $event->getMarkerRecordedEventAttributes(); - if ($attributes === null || $attributes->getMarkerName() !== self::MARKER_LOCAL_ACTIVITY) { + if ($attributes === null || !\in_array($attributes->getMarkerName(), [self::MARKER_LOCAL_ACTIVITY, self::MARKER_CORE_LOCAL_ACTIVITY], true)) { return null; } - return new RecordedCall( - RecordedCallKind::LocalActivity, - self::localActivityType($attributes), - null, - null, - ); + $type = self::localActivityType($attributes); + if ($type === ActivityTasks::SIDE_EFFECT) { + return null; + } + return new RecordedCall(RecordedCallKind::LocalActivity, $type, null, null); default: return null; } } - private static function localActivityType(MarkerRecordedEventAttributes $attributes): string - { - foreach ($attributes->getDetails() as $key => $payloads) { - if ($key !== self::MARKER_DETAIL_DATA) { - continue; - } - - $items = $payloads->getPayloads(); - if (\count($items) === 0) { - return ''; - } - - $decoded = \json_decode($items[0]->getData(), true); - - return \is_array($decoded) ? (string) ($decoded[self::MARKER_ACTIVITY_TYPE_KEY] ?? '') : ''; - } - - return ''; - } - private static function durationToMs(?Duration $duration): int { if ($duration === null) { diff --git a/testing/src/Replay/CoreWorkflowReplayer.php b/testing/src/Replay/CoreWorkflowReplayer.php new file mode 100644 index 000000000..beb7c50b6 --- /dev/null +++ b/testing/src/Replay/CoreWorkflowReplayer.php @@ -0,0 +1,85 @@ +getEvents(); + $workflowType = \count($events) === 0 + ? null + : $events[0]->getWorkflowExecutionStartedEventAttributes()?->getWorkflowType()?->getName(); + if ($workflowType === null) { + throw new \LogicException('History is empty or broken.'); + } + + $this->replay($workflowType, $history, self::REPLAY_WORKFLOW_ID); + } + + public function replayFromServer(string $workflowType, WorkflowExecution $execution): void + { + $this->replay($workflowType, $this->fetchHistory($execution), $execution->getID()); + } + + public function downloadHistory(string $workflowType, WorkflowExecution $execution, string $savePath): void + { + if (\file_put_contents($savePath, $this->fetchHistory($execution)->serializeToJsonString()) === false) { + throw new \RuntimeException(\sprintf('Unable to write the history to "%s"', $savePath)); + } + } + + public function replayFromJSON(string $workflowType, string|\SplFileInfo $path, int $lastEventId = 0): void + { + $path = $path instanceof \SplFileInfo ? $path->getPathname() : $path; + if (!\is_file($path)) { + throw new InvalidArgumentException($workflowType, \sprintf('History file "%s" does not exist.', $path), StatusCode::INVALID_ARGUMENT); + } + + $json = \file_get_contents($path); + if ($json === false) { + throw new InvalidArgumentException($workflowType, \sprintf('History file "%s" cannot be read.', $path), StatusCode::INVALID_ARGUMENT); + } + + $this->replay($workflowType, HistoryJsonCodec::decode($json, $lastEventId), self::REPLAY_WORKFLOW_ID); + } + + private function replay(string $workflowType, History $history, string $workflowId): void + { + try { + $this->factory->replay($history, $workflowId); + } catch (ReplayFailedException $e) { + throw $e->nonDeterministic + ? new NonDeterministicWorkflowException($workflowType, $e->getMessage(), StatusCode::FAILED_PRECONDITION, $e) + : new ReplayerException($workflowType, $e->getMessage(), StatusCode::INTERNAL, $e); + } + } + + private function fetchHistory(WorkflowExecution $execution): History + { + if ($this->client === null) { + throw new \LogicException('A workflow client is required to fetch a history.'); + } + + return $this->client->getWorkflowHistory($execution)->getHistory(); + } +} diff --git a/testing/src/Replay/HistoryJsonCodec.php b/testing/src/Replay/HistoryJsonCodec.php new file mode 100644 index 000000000..6e3ffdf8e --- /dev/null +++ b/testing/src/Replay/HistoryJsonCodec.php @@ -0,0 +1,72 @@ +getDescriptorByClassName(History::class)); + + $history = new History(); + $history->mergeFromJsonString(\json_encode($data, \JSON_THROW_ON_ERROR), true); + + $events = []; + foreach ($history->getEvents() as $event) { + $events[] = $event; + if ($event->getEventId() === $lastEventId) { + break; + } + } + $history->setEvents($events); + + return $history; + } + + private static function normalizeEnums(\stdClass $message, Descriptor $descriptor): void + { + for ($i = 0; $i < $descriptor->getFieldCount(); ++$i) { + $field = $descriptor->getField($i); + $key = \lcfirst(\str_replace('_', '', \ucwords($field->getName(), '_'))); + if (!isset($message->{$key})) { + $key = $field->getName(); + } + if (!isset($message->{$key}) || $field->isMap()) { + continue; + } + + $values = $field->isRepeated() ? $message->{$key} : [$message->{$key}]; + if ($field->getType() === GPBType::MESSAGE) { + foreach ($values as $value) { + if ($value instanceof \stdClass) { + self::normalizeEnums($value, $field->getMessageType()); + } + } + } + + if ($field->getType() === GPBType::ENUM) { + $names = \array_map(static fn(mixed $value): mixed => \is_string($value) ? self::enumName($field->getEnumType(), $value) : $value, $values); + $message->{$key} = $field->isRepeated() ? $names : $names[0]; + } + } + } + + private static function enumName(EnumDescriptor $enum, string $value): string + { + $prefix = \substr($enum->getValue(0)->getName(), 0, -\strlen(self::UNSPECIFIED_SUFFIX)); + $name = \strtoupper((string) \preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', '_', $value)); + + return \str_starts_with($name, $prefix) ? $name : $prefix . $name; + } +} diff --git a/tests/Acceptance/.rr.core.yaml b/tests/Acceptance/.rr.core.yaml new file mode 100644 index 000000000..76769e08f --- /dev/null +++ b/tests/Acceptance/.rr.core.yaml @@ -0,0 +1,27 @@ +version: "3" +rpc: + listen: ${ROADRUNNER_ADDRESS:-tcp://127.0.0.1:6001} + +service: + workflow: + command: "php worker.php" + process_num: 1 + remain_after_exit: true + restart_sec: 1 + env: + TEMPORAL_CORE_ROLE: workflow + activity: + command: "php worker.php" + process_num: 2 + remain_after_exit: true + restart_sec: 1 + env: + TEMPORAL_CORE_ROLE: activity + +kv: + harness: + driver: memory + config: { } + +logs: + mode: raw diff --git a/tests/Acceptance/App/Runtime/RRStarter.php b/tests/Acceptance/App/Runtime/RRStarter.php index 694f13306..407a591d8 100644 --- a/tests/Acceptance/App/Runtime/RRStarter.php +++ b/tests/Acceptance/App/Runtime/RRStarter.php @@ -4,6 +4,8 @@ namespace Temporal\Tests\Acceptance\App\Runtime; +use Symfony\Component\Process\Process; +use Temporal\Tests\CoreWorker; use Temporal\Testing\Environment; use Temporal\Testing\SystemInfo; use Temporal\Testing\Transcript\TranscriptStore; @@ -11,6 +13,7 @@ final class RRStarter { private Environment $environment; + private ?Process $coreWorker = null; public function __construct( private State $runtime, @@ -22,7 +25,7 @@ public function __construct( public function start(): void { - if ($this->environment->isRoadRunnerRunning()) { + if ($this->environment->isRoadRunnerRunning() || $this->coreWorker?->isRunning()) { return; } @@ -42,8 +45,9 @@ public function start(): void $workerArgs[] = 'test-class=' . $class; } + $rrBinary = $this->runtime->workDir . DIRECTORY_SEPARATOR . $systemInfo->rrExecutable; $rrCommand = [ - $this->runtime->workDir . DIRECTORY_SEPARATOR . $systemInfo->rrExecutable, + $rrBinary, 'serve', '-w', $this->runtime->rrConfigDir, @@ -69,6 +73,11 @@ public function start(): void $envs['TEMPORAL_TRANSCRIPT_RUN_ID'] = $runId; } + if (CoreWorker::enabled()) { + $this->startCoreWorker($rrBinary, $workerArgs, $envs); + return; + } + $this->environment->startRoadRunner( rrCommand: $rrCommand, envs: $envs, @@ -78,9 +87,39 @@ public function start(): void public function stop(): void { + if ($this->coreWorker?->isRunning()) { + \exec('pgrep -P ' . $this->coreWorker->getPid(), $children); + $this->coreWorker->stop(3); + foreach ($children as $child) { + \posix_kill((int) $child, \SIGKILL); + } + } + $this->coreWorker = null; $this->environment->stopRoadRunner(); } + private function startCoreWorker(string $rrBinary, array $workerArgs, array $envs): void + { + $workerCommand = \implode(' ', $workerArgs); + $this->coreWorker = CoreWorker::start( + [ + $rrBinary, + 'serve', + '-c', + '.rr.core.yaml', + '-o', + "service.workflow.command=$workerCommand", + '-o', + "service.activity.command=$workerCommand", + '-o', + "service.activity.process_num={$this->runtime->activityWorkers}", + ], + $this->runtime->rrConfigDir, + $this->runtime->workDir . '/runtime/tests/core-worker.log', + ['TEMPORAL_TRANSCRIPT_DIR' => 'runtime/tests/transcripts'] + $envs, + ); + } + public function __destruct() { $this->stop(); diff --git a/tests/Acceptance/App/Support.php b/tests/Acceptance/App/Support.php index 704736f9c..6ffe91386 100644 --- a/tests/Acceptance/App/Support.php +++ b/tests/Acceptance/App/Support.php @@ -4,8 +4,32 @@ namespace Temporal\Tests\Acceptance\App; +use Temporal\Api\History\V1\HistoryEvent; +use Temporal\Api\History\V1\MarkerRecordedEventAttributes; +use Temporal\Worker\Core\ActivityTasks; + final class Support { + public static function isSideEffectMarker(HistoryEvent $event): bool + { + $marker = $event->getMarkerRecordedEventAttributes(); + + return match ($marker?->getMarkerName()) { + 'SideEffect' => true, + 'core_local_activity' => self::coreLocalActivityType($marker) === ActivityTasks::SIDE_EFFECT, + default => false, + }; + } + + private static function coreLocalActivityType(MarkerRecordedEventAttributes $marker): string + { + $data = $marker->getDetails()['data'] ?? null; + $payload = $data?->getPayloads()[0] ?? null; + $decoded = $payload === null ? null : \json_decode($payload->getData(), true); + + return \is_array($decoded) ? (string) ($decoded['activity_type'] ?? '') : ''; + } + public static function echoException(\Throwable $e): void { $trace = \array_filter($e->getTrace(), static fn(array $trace): bool => diff --git a/tests/Acceptance/App/TaskQueueResolver.php b/tests/Acceptance/App/TaskQueueResolver.php index 399ab18fc..403988925 100644 --- a/tests/Acceptance/App/TaskQueueResolver.php +++ b/tests/Acceptance/App/TaskQueueResolver.php @@ -23,6 +23,7 @@ final class TaskQueueResolver \Temporal\Tests\Acceptance\Extra\Versioning\Classic\ClassicTest::class, \Temporal\Tests\Acceptance\Extra\Versioning\Deployment\DeploymentTest::class, \Temporal\Tests\Acceptance\Extra\Activity\ActivityPaused\ActivityPausedTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\NondeterminismFailWorkflow\NondeterminismFailWorkflowTest::class, ]; /** diff --git a/tests/Acceptance/App/Transport/RecordingCoreWire.php b/tests/Acceptance/App/Transport/RecordingCoreWire.php new file mode 100644 index 000000000..9c55347cb --- /dev/null +++ b/tests/Acceptance/App/Transport/RecordingCoreWire.php @@ -0,0 +1,43 @@ + $type + */ + public function __invoke(string $type, string $bytes): void + { + $message = new $type(); + $message->mergeFromString($bytes); + $frame = $message->serializeToJsonString(); + + if (\in_array($type, self::INBOUND, true)) { + $this->inboundBatchId++; + $this->outboundSeq = 0; + $this->transcript->writeWireInbound($frame, [], $this->inboundBatchId); + return; + } + + $this->outboundSeq++; + $this->transcript->writeWireOutbound($frame, $this->inboundBatchId, $this->outboundSeq); + } +} diff --git a/tests/Acceptance/Extra/Client/WorkflowClientTest.php b/tests/Acceptance/Extra/Client/WorkflowClientTest.php index 9b97ec7f5..55f725a42 100644 --- a/tests/Acceptance/Extra/Client/WorkflowClientTest.php +++ b/tests/Acceptance/Extra/Client/WorkflowClientTest.php @@ -52,7 +52,8 @@ public function describeWorkflowExecution( self::assertGreaterThanOrEqual(2, $description->info->historyLength); self::assertNull($description->info->parentExecution); self::assertNotNull($description->info->executionTime); - self::assertCount(7, $description->info->searchAttributes); + $searchAttributes = \iterator_to_array($description->info->searchAttributes->getValues()); + self::assertCount(7, \array_diff_key($searchAttributes, ['BuildIds' => true])); self::assertCount(4, $description->info->memo); self::assertNull($description->info->executionDuration); self::assertSame($description->info->firstRunId, $description->info->execution->getRunID()); diff --git a/tests/Acceptance/Extra/Stability/ResetWorkerTest.php b/tests/Acceptance/Extra/Stability/ResetWorkerTest.php index 583de204b..fde3042b4 100644 --- a/tests/Acceptance/Extra/Stability/ResetWorkerTest.php +++ b/tests/Acceptance/Extra/Stability/ResetWorkerTest.php @@ -13,6 +13,7 @@ use Temporal\Exception\Client\WorkflowServiceException; use Temporal\Exception\Failure\CanceledFailure; use Temporal\Tests\Acceptance\App\Runtime\Feature; +use Temporal\Tests\Acceptance\App\Support; use Temporal\Tests\Acceptance\App\TestCase; use Temporal\Workflow; use Temporal\Workflow\ReturnType; @@ -100,8 +101,7 @@ public function resetWithSignal( $found = false; foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { if ($event->hasMarkerRecordedEventAttributes()) { - $record = $event->getMarkerRecordedEventAttributes(); - self::assertSame('SideEffect', $record->getMarkerName()); + self::assertTrue(Support::isSideEffectMarker($event)); $found = true; break; } diff --git a/tests/Acceptance/Extra/Versioning/Classic/Versioning-v1-core.json b/tests/Acceptance/Extra/Versioning/Classic/Versioning-v1-core.json new file mode 100644 index 000000000..a505b36b8 --- /dev/null +++ b/tests/Acceptance/Extra/Versioning/Classic/Versioning-v1-core.json @@ -0,0 +1,170 @@ +{ + "events": [ + { + "eventId": "1", + "eventTime": "2026-10-05T09:43:22.792768Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", + "taskId": "1048587", + "workflowExecutionStartedEventAttributes": { + "workflowType": { + "name": "Extra_Versioning_Classic" + }, + "taskQueue": { + "name": "Temporal\\Tests\\Acceptance\\Extra\\Versioning\\Classic", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "workflowExecutionTimeout": "60s", + "workflowRunTimeout": "60s", + "workflowTaskTimeout": "10s", + "originalExecutionRunId": "01a10b72-4da8-7bb4-b36a-1f0c692f7fe3", + "identity": "81774@MacBook-Pro.local", + "firstExecutionRunId": "01a10b72-4da8-7bb4-b36a-1f0c692f7fe3", + "attempt": 1, + "workflowExecutionExpirationTime": "2026-10-05T09:44:22.793Z", + "firstWorkflowTaskBackoff": "0s", + "workflowId": "d5a90bec-1415-4542-9a6a-20dbba0a105b", + "priority": {} + } + }, + { + "eventId": "2", + "eventTime": "2026-10-05T09:43:22.792809Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048588", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "Temporal\\Tests\\Acceptance\\Extra\\Versioning\\Classic", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "3", + "eventTime": "2026-10-05T09:43:22.794409Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048594", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "2", + "identity": "81799@MacBook-Pro.local", + "requestId": "b9e43635-8950-4d25-862b-452f1817765b", + "historySizeBytes": "377" + } + }, + { + "eventId": "4", + "eventTime": "2026-10-05T09:43:22.808477Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048598", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "2", + "startedEventId": "3", + "identity": "81799@MacBook-Pro.local", + "workerVersion": {}, + "sdkMetadata": { + "coreUsedFlags": [ + 1, + 3, + 2 + ], + "sdkName": "temporal-php-2" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "5", + "eventTime": "2026-10-05T09:43:22.808536Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "taskId": "1048599", + "markerRecordedEventAttributes": { + "markerName": "core_patch", + "details": { + "patch-data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJpZCI6InRlc3QtMSIsImRlcHJlY2F0ZWQiOmZhbHNlfQ==" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "6", + "eventTime": "2026-10-05T09:43:22.808749Z", + "eventType": "EVENT_TYPE_UPSERT_WORKFLOW_SEARCH_ATTRIBUTES", + "taskId": "1048600", + "upsertWorkflowSearchAttributesEventAttributes": { + "workflowTaskCompletedEventId": "4", + "searchAttributes": { + "indexedFields": { + "TemporalChangeVersion": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==", + "type": "S2V5d29yZExpc3Q=" + }, + "data": "WyJ0ZXN0LTEiXQ==" + } + } + } + } + }, + { + "eventId": "7", + "eventTime": "2026-10-05T09:43:22.808777Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "taskId": "1048601", + "markerRecordedEventAttributes": { + "markerName": "core_local_activity", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJzZXEiOjEsImF0dGVtcHQiOjEsImFjdGl2aXR5X2lkIjoiMSIsImFjdGl2aXR5X3R5cGUiOiJfX3BocF9zaWRlX2VmZmVjdCIsImNvbXBsZXRlX3RpbWUiOnsic2Vjb25kcyI6MTc5MTE5MzQwMiwibmFub3MiOjc5NDk5NTcwOX0sImJhY2tvZmYiOm51bGwsIm9yaWdpbmFsX3NjaGVkdWxlX3RpbWUiOnsic2Vjb25kcyI6MTc5MTE5MzQwMiwibmFub3MiOjc5NDQwOTAwMH19" + } + ] + }, + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InRlc3Qi" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "8", + "eventTime": "2026-10-05T09:43:22.808785Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", + "taskId": "1048602", + "workflowExecutionCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InYxIg==" + } + ] + }, + "workflowTaskCompletedEventId": "4" + } + } + ] +} diff --git a/tests/Acceptance/Extra/Versioning/ClassicTest.php b/tests/Acceptance/Extra/Versioning/ClassicTest.php index d46f278c6..608051662 100644 --- a/tests/Acceptance/Extra/Versioning/ClassicTest.php +++ b/tests/Acceptance/Extra/Versioning/ClassicTest.php @@ -8,10 +8,15 @@ use Temporal\Activity\ActivityInterface; use Temporal\Activity\ActivityMethod; use Temporal\Activity\ActivityOptions; +use Temporal\Client\WorkflowClientInterface; use Temporal\Client\WorkflowStubInterface; +use Temporal\Testing\Replay\CoreWorkflowReplayer; use Temporal\Testing\Replay\WorkflowReplayer; use Temporal\Tests\Acceptance\App\Attribute\Stub; +use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Tests\Acceptance\App\TestCase; +use Temporal\Tests\CoreWorker; +use Temporal\Worker\Core\CoreWorkerFactory; use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; @@ -23,16 +28,29 @@ public function replayDifferentVersions( #[Stub( type: 'Extra_Versioning_Classic', )] WorkflowStubInterface $stub, + WorkflowClientInterface $client, + Feature $feature, ): void { $result = $stub->getResult(); self::assertSame('v2', $result); - $replayer = new WorkflowReplayer(); + $replayer = CoreWorker::enabled() ? self::coreReplayer($client, $feature) : new WorkflowReplayer(); $replayer->replayFromJSON('Extra_Versioning_Classic', __DIR__ . '/Classic/Versioning-default.json'); - $replayer->replayFromJSON('Extra_Versioning_Classic', __DIR__ . '/Classic/Versioning-v1.json'); + $replayer->replayFromJSON( + 'Extra_Versioning_Classic', + __DIR__ . (CoreWorker::enabled() ? '/Classic/Versioning-v1-core.json' : '/Classic/Versioning-v1.json'), + ); $replayer->replayFromServer($stub->getWorkflowType(), $stub->getExecution()); } + + private static function coreReplayer(WorkflowClientInterface $client, Feature $feature): CoreWorkflowReplayer + { + $factory = CoreWorkerFactory::create(); + $factory->newWorker($feature->taskQueue)->registerWorkflowTypes(TestWorkflow::class); + + return new CoreWorkflowReplayer($factory, $client); + } } #[WorkflowInterface] diff --git a/tests/Acceptance/Extra/Workflow/DateTimeZoneWorkflowTest.php b/tests/Acceptance/Extra/Workflow/DateTimeZoneWorkflowTest.php index a611aa185..683c8dfc0 100644 --- a/tests/Acceptance/Extra/Workflow/DateTimeZoneWorkflowTest.php +++ b/tests/Acceptance/Extra/Workflow/DateTimeZoneWorkflowTest.php @@ -21,7 +21,9 @@ public static function currentTime( ): void { $result = $stub->getResult(type: 'array'); - self::assertEquals($result['system'], $result['current']); + self::assertSame($result['system']['timezone.offset'], $result['current']['timezone.offset']); + self::assertGreaterThanOrEqual($result['system']['timestamp'], $result['current']['timestamp']); + self::assertLessThan($result['system']['timestamp'] + 5, $result['current']['timestamp']); } } diff --git a/tests/Acceptance/Extra/Workflow/NondeterminismFailWorkflowTest.php b/tests/Acceptance/Extra/Workflow/NondeterminismFailWorkflowTest.php new file mode 100644 index 000000000..24ed9449e --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/NondeterminismFailWorkflowTest.php @@ -0,0 +1,154 @@ +withTimeout(1)->newUntypedWorkflowStub( + 'Extra_Workflow_NondeterminismFailWorkflow', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withWorkflowExecutionTimeout(30), + ); + $client->start($stub, $marker); + $this->waitUntil( + fn(): bool => $this->firstWorkflowTaskCompleted($client, $stub), + 'The first workflow task did not complete', + ); + $pollers = $this->pollerIdentities($client, $runtime->namespace, $feature->taskQueue); + + try { + $stub->query('die'); + } catch (WorkflowServiceException) { + } + $this->waitUntil( + fn(): bool => \array_diff($this->pollerIdentities($client, $runtime->namespace, $feature->taskQueue), $pollers) !== [], + 'No replacement worker polls the task queue', + ); + $stub->signal('go'); + + try { + $stub->getResult(timeout: 20); + self::fail('The workflow must fail on nondeterminism'); + } catch (WorkflowFailedException) { + self::assertTrue(true); + } finally { + @\unlink($marker); + } + } + + private function waitUntil(\Closure $condition, string $failure): void + { + $deadline = \microtime(true) + self::WAIT_TIMEOUT_SECONDS; + while (\microtime(true) < $deadline) { + if ($condition()) { + return; + } + \usleep(self::POLL_MICROSECONDS); + } + self::fail($failure); + } + + private function firstWorkflowTaskCompleted(WorkflowClientInterface $client, WorkflowStubInterface $stub): bool + { + foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { + if ($event->getEventType() === EventType::EVENT_TYPE_WORKFLOW_TASK_COMPLETED) { + return true; + } + } + return false; + } + + /** + * @return list + */ + private function pollerIdentities(WorkflowClientInterface $client, string $namespace, string $taskQueue): array + { + $response = $client->getServiceClient()->DescribeTaskQueue( + (new DescribeTaskQueueRequest()) + ->setNamespace($namespace) + ->setTaskQueue((new TaskQueue())->setName($taskQueue)) + ->setTaskQueueType(TaskQueueType::TASK_QUEUE_TYPE_WORKFLOW), + ); + + return \array_map( + static fn(PollerInfo $poller): string => $poller->getIdentity(), + \iterator_to_array($response->getPollers()), + ); + } +} + +class WorkerFactory +{ + public static function options(): WorkerOptions + { + return WorkerOptions::new()->withWorkflowPanicPolicy(WorkflowPanicPolicy::FailWorkflow); + } +} + +#[Workflow\WorkflowInterface] +class TestWorkflow +{ + private bool $go = false; + + #[WorkflowMethod('Extra_Workflow_NondeterminismFailWorkflow')] + public function run(string $marker): \Generator + { + if (\file_exists($marker)) { + yield Workflow::timer(1); + } else { + \touch($marker); + yield Workflow::sideEffect(static fn(): int => 1); + } + + yield Workflow::await(fn(): bool => $this->go); + + return 'done'; + } + + #[Workflow\QueryMethod('die')] + public function die(): void + { + \sleep(2); + exit(1); + } + + #[Workflow\SignalMethod('go')] + public function go(): void + { + $this->go = true; + } +} diff --git a/tests/Acceptance/Extra/Workflow/SideEffectTest.php b/tests/Acceptance/Extra/Workflow/SideEffectTest.php index 006fde41b..1083b6084 100644 --- a/tests/Acceptance/Extra/Workflow/SideEffectTest.php +++ b/tests/Acceptance/Extra/Workflow/SideEffectTest.php @@ -11,6 +11,7 @@ use Temporal\Common\SideEffectOptions; use Temporal\DataConverter\DataConverterInterface; use Temporal\Tests\Acceptance\App\Attribute\Stub; +use Temporal\Tests\Acceptance\App\Support; use Temporal\Tests\Acceptance\App\TestCase; use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; @@ -25,7 +26,9 @@ public static function currentTime( ): void { $result = $stub->getResult(type: 'array'); - self::assertEquals($result['system'], $result['current']); + self::assertSame($result['system']['timezone.offset'], $result['current']['timezone.offset']); + self::assertGreaterThanOrEqual($result['system']['timestamp'], $result['current']['timestamp']); + self::assertLessThan($result['system']['timestamp'] + 5, $result['current']['timestamp']); } #[Test] @@ -66,10 +69,7 @@ public static function noSummaryWhenOptionsOmitted( $markerCount = 0; foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { - if (!$event->hasMarkerRecordedEventAttributes()) { - continue; - } - if ($event->getMarkerRecordedEventAttributes()->getMarkerName() !== 'SideEffect') { + if (!Support::isSideEffectMarker($event)) { continue; } @@ -90,10 +90,7 @@ private static function collectSideEffectSummaries( ): array { $summaries = []; foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { - if (!$event->hasMarkerRecordedEventAttributes()) { - continue; - } - if ($event->getMarkerRecordedEventAttributes()->getMarkerName() !== 'SideEffect') { + if (!Support::isSideEffectMarker($event)) { continue; } diff --git a/tests/Acceptance/Extra/Workflow/WorkflowSearchAttributesTest.php b/tests/Acceptance/Extra/Workflow/WorkflowSearchAttributesTest.php index 219a00589..728c78a6a 100644 --- a/tests/Acceptance/Extra/Workflow/WorkflowSearchAttributesTest.php +++ b/tests/Acceptance/Extra/Workflow/WorkflowSearchAttributesTest.php @@ -31,7 +31,7 @@ public function sendEmptySearchAttributes( WorkflowStubInterface $stub, ): void { $result = $stub->getResult(timeout: 3); - $this->assertSame([], $result, 'Workflow result contains resolved value'); + $this->assertEmpty($result, 'Empty search attributes reach the child as no search attributes'); } #[Test] diff --git a/tests/Acceptance/worker.php b/tests/Acceptance/worker.php index 08da5fa6d..20d7515e6 100644 --- a/tests/Acceptance/worker.php +++ b/tests/Acceptance/worker.php @@ -33,7 +33,10 @@ use Temporal\Tests\Acceptance\App\Runtime\State; use Temporal\Tests\Acceptance\App\RuntimeBuilder; use Temporal\Worker\Logger\StderrLogger; +use Temporal\Tests\Acceptance\App\Transport\RecordingCoreWire; use Temporal\Tests\Acceptance\App\Transport\RecordingHost; +use Temporal\Worker\Core\CoreWorkerFactory; +use Temporal\Tests\CoreWorker; use Temporal\Worker\Transport\RoadRunner; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; @@ -98,13 +101,22 @@ $container->bindSingleton(DataConverter::class, $converter); $plugins = [new TranscriptPlugin($workerTranscript)]; - $container->bindSingleton( - WorkerFactoryInterface::class, - WorkerFactory::create( + $coreTransport = CoreWorker::enabled(); + if ($coreTransport) { + $factory = CoreWorkerFactory::create( converter: $converter, pluginRegistry: new PluginRegistry($plugins), - ) - ); + address: $runtime->address, + namespace: $runtime->namespace, + ); + $factory->observeWire((new RecordingCoreWire($workerTranscript))(...)); + } else { + $factory = WorkerFactory::create( + converter: $converter, + pluginRegistry: new PluginRegistry($plugins), + ); + } + $container->bindSingleton(WorkerFactoryInterface::class, $factory); $workerFactory = $container->get(\Temporal\Tests\Acceptance\App\Feature\WorkerFactory::class); $getWorker = static function (Feature $feature) use (&$workers, $workerFactory): WorkerInterface { @@ -141,8 +153,8 @@ $getWorker($feature)->registerActivityImplementations($container->make($activity)); } - $host = new RecordingHost(RoadRunner::create(), $workerTranscript); - $container->get(WorkerFactoryInterface::class)->run($host); + $host = $coreTransport ? null : new RecordingHost(RoadRunner::create(), $workerTranscript); + exit($container->get(WorkerFactoryInterface::class)->run($host)); } catch (\Throwable $e) { $workerTranscript->writeFatal($e); $workerTranscript->flush(); diff --git a/tests/Core/Client/ClientTransportTestCase.php b/tests/Core/Client/ClientTransportTestCase.php new file mode 100644 index 000000000..3aaa13895 --- /dev/null +++ b/tests/Core/Client/ClientTransportTestCase.php @@ -0,0 +1,90 @@ +withContext(self::singleAttempt()); + + $this->expectUnavailable(); + + $client->GetSystemInfo(new GetSystemInfoRequest()); + } + + public function testConnectionIsConnectedAfterAnAnsweredCall(): void + { + $client = ServiceClient::create(DevServer::address()); + + $client->GetSystemInfo(new GetSystemInfoRequest()); + + $this->assertTrue($client->getConnection()->isConnected()); + } + + public function testOperatorClientReportsUnavailableServer(): void + { + $client = OperatorClient::create(self::CLOSED_ADDRESS)->withContext(self::singleAttempt()); + + $this->expectUnavailable(); + + $client->ListSearchAttributes(new ListSearchAttributesRequest()); + } + + public function testCloudClientOverTlsReportsUnavailableServer(): void + { + $client = CloudClient::createSSL(self::CLOSED_ADDRESS, self::certificate(), overrideServerName: 'localhost') + ->withContext(self::singleAttempt()); + + $this->expectUnavailable(); + + $client->GetNamespaces(new GetNamespacesRequest()); + } + + public function testTestServiceReportsUnavailableServer(): void + { + $service = TestService::create(self::CLOSED_ADDRESS); + + $this->expectUnavailable(); + + $service->lockTimeSkipping(); + } + + private function expectUnavailable(): void + { + $this->expectException(ServiceClientException::class); + $this->expectExceptionCode(StatusCode::UNAVAILABLE); + } + + private static function singleAttempt(): Context + { + return Context::default()->withRetryOptions(RpcRetryOptions::new()->withMaximumAttempts(1)); + } + + private static function certificate(): string + { + $key = \openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + $certificate = \openssl_csr_sign(\openssl_csr_new(['commonName' => 'localhost'], $key), null, $key, 1); + \openssl_x509_export($certificate, $pem); + + return $pem; + } +} diff --git a/tests/Core/Client/GrpcStubTestCase.php b/tests/Core/Client/GrpcStubTestCase.php new file mode 100644 index 000000000..859b91a7b --- /dev/null +++ b/tests/Core/Client/GrpcStubTestCase.php @@ -0,0 +1,486 @@ +connection !== null) { + \fclose($this->connection); + } + if ($this->listener !== null) { + \fclose($this->listener); + } + } + + public static function provideStubClasses(): iterable + { + yield 'workflow service' => [WorkflowServiceClient::class]; + yield 'operator service' => [OperatorServiceClient::class]; + yield 'cloud service' => [CloudServiceClient::class]; + yield 'test service' => [TestServiceClient::class]; + } + + /** + * @param class-string $class + */ + #[DataProvider('provideStubClasses')] + public function testStubIsIdleUntilItConnects(string $class): void + { + $stub = new $class(self::CLOSED_ADDRESS, ['credentials' => ChannelCredentials::createInsecure(), 'force_new' => true]); + + $this->assertSame('dns:///' . self::CLOSED_ADDRESS, $stub->getTarget()); + $this->assertSame(ConnectionState::Idle->value, $stub->getConnectivityState()); + } + + public static function provideTargets(): iterable + { + yield 'host and port' => ['localhost:7233', 'dns:///localhost:7233']; + yield 'dns' => ['dns:///localhost:7233', 'dns:///localhost:7233']; + yield 'ipv4' => ['ipv4:127.0.0.1:7233', 'ipv4:127.0.0.1:7233']; + yield 'ipv6' => ['ipv6:[::1]:7233', 'ipv6:[::1]:7233']; + yield 'unix' => ['unix:/tmp/temporal.sock', 'unix:/tmp/temporal.sock']; + } + + #[DataProvider('provideTargets')] + public function testTargetIsReportedAsExtGrpcReportsIt(string $address, string $target): void + { + $this->assertSame($target, self::stub($address)->getTarget()); + } + + public function testClosedPortEndsInTransientFailure(): void + { + $stub = self::stub(self::CLOSED_ADDRESS); + + $this->assertFalse($stub->waitForReady(self::BRIEF_WAIT_MICROSECONDS)); + $this->assertSame(ConnectionState::TransientFailure->value, $stub->getConnectivityState()); + } + + public function testCallToClosedPortReturnsUnavailable(): void + { + $stub = self::stub(self::CLOSED_ADDRESS); + + [$response, $status] = $stub->GetSystemInfo( + new GetSystemInfoRequest(), + ['Trace-Bin' => ["\x00\x01"], 'Trace' => ['plain']], + ['timeout' => self::WAIT_FOR_FAILURE_MICROSECONDS], + )->wait(); + + $this->assertNull($response); + $this->assertSame(StatusCode::UNAVAILABLE, $status->code); + $this->assertNotSame('', $status->details); + $this->assertSame([], $status->metadata); + } + + public function testTimeoutLongerThanGrpcCanEncodeIsCapped(): void + { + $stub = self::stub(self::CLOSED_ADDRESS); + + [, $status] = $stub->GetSystemInfo(new GetSystemInfoRequest(), [], ['timeout' => self::TIMEOUT_BEYOND_GRPC_MICROSECONDS])->wait(); + + $this->assertSame(StatusCode::UNAVAILABLE, $status->code); + } + + public static function provideLargestTimeouts(): iterable + { + yield 'largest integer' => [\PHP_INT_MAX]; + yield 'deadline beyond the largest integer' => [\PHP_INT_MAX - 1]; + } + + #[DataProvider('provideLargestTimeouts')] + public function testLargestIntegerTimeoutIsAccepted(int $timeout): void + { + $stub = self::stub(self::CLOSED_ADDRESS); + + [, $status] = $stub->GetSystemInfo(new GetSystemInfoRequest(), [], ['timeout' => $timeout])->wait(); + + $this->assertSame(StatusCode::UNAVAILABLE, $status->code); + } + + public function testInvalidMetadataKeyIsRejectedBeforeTheCall(): void + { + $stub = self::stub(self::CLOSED_ADDRESS); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Metadata keys must be nonempty strings containing only alphanumeric characters, hyphens, underscores and dots'); + + $stub->GetSystemInfo(new GetSystemInfoRequest(), ['bad key' => ['x']]); + } + + public function testCallResultIsTakenOnce(): void + { + $call = self::stub(self::CLOSED_ADDRESS)->GetSystemInfo(new GetSystemInfoRequest()); + $call->wait(); + + $this->expectExceptionObject(new \LogicException('start_batch was called incorrectly', 8)); + + $call->wait(); + } + + public function testHandshakeThatNeverEndsStaysConnectingUntilClosed(): void + { + $stub = self::stub($this->silentListener(), ChannelCredentials::createSsl()); + + $this->assertSame(ConnectionState::Idle->value, $stub->getConnectivityState(true)); + $this->assertFalse($stub->waitForReady(self::SHORT_WAIT_MICROSECONDS)); + $this->assertSame(ConnectionState::Connecting->value, $stub->getConnectivityState()); + + $stub->close(); + $stub->close(); + + $this->expectExceptionObject(new \RuntimeException('getConnectivityState error.Channel is already closed.', self::CLOSED_CHANNEL_ERROR_CODE)); + + $stub->getConnectivityState(); + } + + public function testCallWithoutAnAnswerExceedsItsDeadline(): void + { + $stub = $this->readyStub(); + + $this->assertSame(ConnectionState::Ready->value, $stub->getConnectivityState(true)); + $call = $stub->GetSystemInfo(new GetSystemInfoRequest(), [], ['timeout' => self::CALL_TIMEOUT_MICROSECONDS]); + [$response, $status] = $call->wait(); + + $this->assertSame($stub->getTarget(), $call->getPeer()); + + $this->assertNull($response); + $this->assertSame(StatusCode::DEADLINE_EXCEEDED, $status->code); + $this->assertSame('Deadline Exceeded', $status->details); + $this->assertSame([], $status->metadata); + } + + public static function provideBrokenServerReplies(): iterable + { + yield 'connection closed' => [null]; + yield 'not http/2' => ["HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"]; + } + + #[DataProvider('provideBrokenServerReplies')] + public function testConnectionBrokenByTheServerIsUnavailable(?string $reply): void + { + $stub = self::stub($this->silentListener()); + $stub->getConnectivityState(true); + $connection = $this->acceptAndStopListening(); + if ($reply !== null) { + \fread($connection, self::CLIENT_PREFACE_BYTES); + \fwrite($connection, $reply); + } + \fclose($connection); + + [$response, $status] = $stub->GetSystemInfo(new GetSystemInfoRequest(), [], ['timeout' => self::WAIT_FOR_FAILURE_MICROSECONDS])->wait(); + + $this->assertNull($response); + $this->assertSame(StatusCode::UNAVAILABLE, $status->code); + } + + public function testCloseEndsTheCallInFlight(): void + { + $stub = $this->readyStub(); + $call = $stub->GetSystemInfo(new GetSystemInfoRequest(), [], ['timeout' => self::BRIEF_WAIT_MICROSECONDS]); + + $stub->close(); + + $this->expectExceptionObject(new \RuntimeException('startBatch Error. Channel is closed', self::CLOSED_CHANNEL_ERROR_CODE)); + + $call->wait(); + } + + public function testCancelledCallEndsCancelled(): void + { + $stub = $this->readyStub(); + $call = $stub->GetSystemInfo(new GetSystemInfoRequest(), [], ['timeout' => self::BRIEF_WAIT_MICROSECONDS]); + + $call->cancel(); + [$response, $status] = $call->wait(); + + $this->assertNull($response); + $this->assertSame(StatusCode::CANCELLED, $status->code); + $this->assertSame('CANCELLED', $status->details); + } + + public function testClosedStubRejectsCallsAndQueries(): void + { + $stub = self::stub(self::CLOSED_ADDRESS); + $stub->close(); + $closed = static function (\Closure $use): string { + try { + $use(); + } catch (\RuntimeException|\InvalidArgumentException $e) { + return \sprintf('%s(%d): %s', $e::class, $e->getCode(), $e->getMessage()); + } + + return 'no exception'; + }; + + $this->assertSame([ + 'InvalidArgumentException(1): Call cannot be constructed from a closed Channel', + 'RuntimeException(1): getConnectivityState error.Channel is already closed.', + 'RuntimeException(1): getConnectivityState error.Channel is already closed.', + 'RuntimeException(1): getTarget error.Channel is already closed.', + ], [ + $closed(static fn() => $stub->GetSystemInfo(new GetSystemInfoRequest())), + $closed(static fn() => $stub->getConnectivityState(true)), + $closed(static fn() => $stub->waitForReady(self::SHORT_WAIT_MICROSECONDS)), + $closed(static fn() => $stub->getTarget()), + ]); + } + + public function testServerAnswersWithResponseAndWithStatusDetails(): void + { + $stub = self::stub(DevServer::address()); + + $this->assertTrue($stub->waitForReady(self::WAIT_FOR_FAILURE_MICROSECONDS)); + [$info, $ok] = $stub->GetSystemInfo(new GetSystemInfoRequest(), ['Trace-Bin' => ["\x00\x01"], 'Trace' => ['plain']])->wait(); + [$missing, $notFound] = $stub->DescribeNamespace((new DescribeNamespaceRequest())->setNamespace('missing-' . \bin2hex(\random_bytes(4))))->wait(); + + $this->assertInstanceOf(GetSystemInfoResponse::class, $info); + $this->assertSame(StatusCode::OK, $ok->code); + $this->assertNull($missing); + $this->assertSame(StatusCode::NOT_FOUND, $notFound->code); + $this->assertNotSame('', $notFound->metadata['grpc-status-details-bin'][0]); + } + + public function testCallCredentialsNeedASecureChannel(): void + { + $invoked = false; + $call = self::stub(DevServer::address())->GetSystemInfo( + new GetSystemInfoRequest(), + [], + ['call_credentials_callback' => static function () use (&$invoked): array { + $invoked = true; + + return []; + }], + ); + + [$response, $status] = $call->wait(); + + $this->assertNull($response); + $this->assertSame(StatusCode::UNAUTHENTICATED, $status->code); + $this->assertFalse($invoked); + } + + public function testCallCredentialsAddMetadataOnASecureChannel(): void + { + $certificate = self::selfSignedCertificate(); + $listener = \stream_socket_server(self::FREE_PORT_PROBE, context: \stream_context_create(['ssl' => [ + 'local_cert' => $certificate, + 'alpn_protocols' => 'h2', + ]])); + $this->assertIsResource($listener); + $this->listener = $listener; + $stub = new WorkflowServiceClient((string) \stream_socket_get_name($listener, false), [ + 'credentials' => ChannelCredentials::createSsl((string) \file_get_contents($certificate)), + 'grpc.ssl_target_name_override' => 'localhost', + 'force_new' => true, + ]); + $stub->getConnectivityState(true); + $connection = \stream_socket_accept($listener, self::WAIT_FOR_FAILURE_MICROSECONDS / 1_000_000); + $this->assertIsResource($connection); + $this->connection = $connection; + $this->assertTrue(\stream_socket_enable_crypto($connection, true, \STREAM_CRYPTO_METHOD_TLS_SERVER)); + \unlink($certificate); + \fwrite($connection, self::HTTP2_SETTINGS_FRAME); + $this->assertTrue($stub->waitForReady(self::WAIT_FOR_FAILURE_MICROSECONDS)); + $contexts = []; + + [, $status] = $stub->GetSystemInfo(new GetSystemInfoRequest(), [], [ + 'timeout' => self::CALL_TIMEOUT_MICROSECONDS, + 'call_credentials_callback' => static function (\stdClass $context) use (&$contexts): array { + $contexts[] = (array) $context; + + return ['authorization' => ['Bearer token']]; + }, + ])->wait(); + + $this->assertSame(StatusCode::DEADLINE_EXCEEDED, $status->code); + $this->assertSame([[ + 'service_url' => 'https://localhost/temporal.api.workflowservice.v1.WorkflowService', + 'method_name' => 'GetSystemInfo', + ]], $contexts); + } + + public static function provideNamedTargets(): iterable + { + yield 'dns with an empty authority' => ['dns:///']; + yield 'dns without an authority' => ['dns:']; + yield 'dns with an authority' => ['dns://127.0.0.53/']; + yield 'ipv4' => ['ipv4:']; + } + + #[DataProvider('provideNamedTargets')] + public function testNamedTargetReachesTheServer(string $prefix): void + { + $stub = self::stub($prefix . DevServer::address()); + + [$info, $status] = $stub->GetSystemInfo(new GetSystemInfoRequest(), [], ['timeout' => self::WAIT_FOR_FAILURE_MICROSECONDS])->wait(); + + $this->assertSame(StatusCode::OK, $status->code, $status->details); + $this->assertInstanceOf(GetSystemInfoResponse::class, $info); + } + + public static function provideUnixTargets(): iterable + { + yield 'unix:/path' => ['unix:']; + yield 'unix:///path' => ['unix://']; + } + + #[DataProvider('provideUnixTargets')] + public function testUnixTargetConnectsToTheSocket(string $prefix): void + { + $socket = \sys_get_temp_dir() . '/tpb-' . \getmypid() . '.sock'; + $listener = \stream_socket_server('unix://' . $socket); + $this->assertIsResource($listener); + $this->listener = $listener; + $stub = self::stub($prefix . $socket); + + $stub->getConnectivityState(true); + $connection = \stream_socket_accept($listener, self::WAIT_FOR_FAILURE_MICROSECONDS / 1_000_000); + \unlink($socket); + $this->assertIsResource($connection); + \fwrite($connection, self::HTTP2_SETTINGS_FRAME); + + $this->assertTrue($stub->waitForReady(self::WAIT_FOR_FAILURE_MICROSECONDS)); + } + + public function testAnsweredCallsMakeTheStubReady(): void + { + $stub = self::stub(DevServer::address()); + + $stub->GetSystemInfo(new GetSystemInfoRequest())->wait(); + $afterResponse = $stub->getConnectivityState(); + $stub->DescribeNamespace((new DescribeNamespaceRequest())->setNamespace('missing-' . \bin2hex(\random_bytes(4))))->wait(); + + $this->assertSame(ConnectionState::Ready->value, $afterResponse); + $this->assertSame(ConnectionState::Ready->value, $stub->getConnectivityState()); + } + + public function testCallToClosedPortMakesTheStubTransientFailure(): void + { + $stub = self::stub(self::CLOSED_ADDRESS); + + $stub->GetSystemInfo(new GetSystemInfoRequest())->wait(); + + $this->assertSame(ConnectionState::TransientFailure->value, $stub->getConnectivityState()); + } + + public function testServerThatGoesAwayIdlesTheStubAndFailsItsCalls(): void + { + $stub = $this->readyStub(); + $call = $stub->GetSystemInfo(new GetSystemInfoRequest(), [], ['timeout' => self::WAIT_FOR_FAILURE_MICROSECONDS]); + \fclose($this->listener); + $this->listener = null; + \fclose($this->connection); + $this->connection = null; + + [, $lost] = $call->wait(); + $idle = $this->stateOnceNotReady($stub); + [, $refused] = $stub->GetSystemInfo(new GetSystemInfoRequest(), [], ['timeout' => self::WAIT_FOR_FAILURE_MICROSECONDS])->wait(); + + $this->assertSame(StatusCode::UNAVAILABLE, $lost->code); + $this->assertSame(ConnectionState::Idle->value, $idle); + $this->assertSame(StatusCode::UNAVAILABLE, $refused->code); + $this->assertSame(ConnectionState::TransientFailure->value, $stub->getConnectivityState()); + } + + private static function stub(string $target, ?ChannelCredentials $credentials = null): WorkflowServiceClient + { + return new WorkflowServiceClient($target, ['credentials' => $credentials, 'force_new' => true]); + } + + private function readyStub(): WorkflowServiceClient + { + $stub = self::stub($this->silentListener()); + $stub->getConnectivityState(true); + $connection = \stream_socket_accept($this->listener, self::WAIT_FOR_FAILURE_MICROSECONDS / 1_000_000); + $this->assertIsResource($connection); + $this->connection = $connection; + \fwrite($connection, self::HTTP2_SETTINGS_FRAME); + $this->assertTrue($stub->waitForReady(self::WAIT_FOR_FAILURE_MICROSECONDS)); + + return $stub; + } + + /** + * @return resource + */ + private function acceptAndStopListening() + { + $connection = \stream_socket_accept($this->listener, self::WAIT_FOR_FAILURE_MICROSECONDS / 1_000_000); + $this->assertIsResource($connection); + \fclose($this->listener); + $this->listener = null; + + return $connection; + } + + private function stateOnceNotReady(WorkflowServiceClient $stub): int + { + $deadline = \microtime(true) + self::BRIEF_WAIT_MICROSECONDS / 1_000_000; + do { + $state = $stub->getConnectivityState(); + } while ($state === ConnectionState::Ready->value && \microtime(true) < $deadline); + + return $state; + } + + private static function selfSignedCertificate(): string + { + $config = (string) \tempnam(\sys_get_temp_dir(), 'cnf'); + \file_put_contents($config, "[req]\ndistinguished_name = dn\n[dn]\n[san]\nsubjectAltName = DNS:localhost\n"); + $options = ['config' => $config, 'x509_extensions' => 'san', 'digest_alg' => 'sha256']; + $key = \openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + $certificate = \openssl_csr_sign(\openssl_csr_new(['commonName' => 'localhost'], $key, $options), null, $key, 1, $options); + \openssl_x509_export($certificate, $pem); + \openssl_pkey_export($key, $keyPem, null, $options); + \unlink($config); + $file = (string) \tempnam(\sys_get_temp_dir(), 'pem'); + \file_put_contents($file, $pem . $keyPem); + + return $file; + } + + private function silentListener(): string + { + $listener = \stream_socket_server(self::FREE_PORT_PROBE); + $this->assertIsResource($listener); + $this->listener = $listener; + + return (string) \stream_socket_get_name($listener, false); + } +} diff --git a/tests/Core/DevServer.php b/tests/Core/DevServer.php new file mode 100644 index 000000000..8772ac297 --- /dev/null +++ b/tests/Core/DevServer.php @@ -0,0 +1,40 @@ +startTemporalServer(parameters: ['--headless']); + \register_shutdown_function(static fn() => $environment->stop()); + self::$address = $address; + } + + return self::$address; + } + + private static function freeAddress(): string + { + $probe = \stream_socket_server(self::FREE_PORT_PROBE); + if ($probe === false) { + throw new \RuntimeException('No free local port for the dev server'); + } + $address = (string) \stream_socket_get_name($probe, false); + \fclose($probe); + + return $address; + } +} diff --git a/tests/Core/Internal/Bridge/BridgeConnectionTestCase.php b/tests/Core/Internal/Bridge/BridgeConnectionTestCase.php new file mode 100644 index 000000000..c95d320f7 --- /dev/null +++ b/tests/Core/Internal/Bridge/BridgeConnectionTestCase.php @@ -0,0 +1,23 @@ +expectExceptionObject(new \InvalidArgumentException("Failed to load certificate from file `$file`.")); + @BridgeConnection::tls($file, null, null, null); + } finally { + \unlink($file); + } + } +} diff --git a/tests/Core/Internal/Bridge/BridgeSharedTestCase.php b/tests/Core/Internal/Bridge/BridgeSharedTestCase.php new file mode 100644 index 000000000..c88bb5ad5 --- /dev/null +++ b/tests/Core/Internal/Bridge/BridgeSharedTestCase.php @@ -0,0 +1,32 @@ +getStaticPropertyValue('shared'); + $sharedPid = $class->getStaticPropertyValue('sharedPid'); + $class->setStaticPropertyValue('shared', $class->newInstanceWithoutConstructor()); + $class->setStaticPropertyValue('sharedPid', self::PARENT_PID); + + try { + $this->expectExceptionObject(new \LogicException(Bridge::FORKED_AFTER_START)); + Bridge::shared(); + } finally { + $class->setStaticPropertyValue('shared', $shared); + $class->setStaticPropertyValue('sharedPid', $sharedPid); + } + } +} diff --git a/tests/Core/Internal/Bridge/BridgeTestCase.php b/tests/Core/Internal/Bridge/BridgeTestCase.php new file mode 100644 index 000000000..bae33e0ba --- /dev/null +++ b/tests/Core/Internal/Bridge/BridgeTestCase.php @@ -0,0 +1,333 @@ +expectExceptionMessage('Unable to read the sdk-core bridge library "/nonexistent/libtemporal_php_bridge.so"'); + self::withServer([CoreEnvironment::BRIDGE_LIB => '/nonexistent/libtemporal_php_bridge.so'], static fn(): Bridge => new Bridge()); + } + + public function testRuntimeErrorIsReported(): void + { + $this->expectExceptionMessage('tpb_runtime_new failed: Invalid Prometheus address not an address'); + self::withServer([CoreEnvironment::PROMETHEUS => 'not an address'], static fn(): Bridge => new Bridge()); + } + + public function testRuntimeNeedsAThread(): void + { + $this->expectException(\InvalidArgumentException::class); + self::withServer([CoreEnvironment::THREADS => '0'], static fn(): Bridge => new Bridge()); + } + + public function testCoreLogsReachTheLogger(): void + { + $bridge = self::withServer([CoreEnvironment::LOG => 'info'], static fn(): Bridge => new Bridge()); + $logger = new LoggerSpy(); + $bridge->useLogger($logger); + + $core = Replayers::create($bridge, self::STARTED_EVENT_ID); + $bridge->nextEvents(0); + $bridge->shutdownWorkers([1 => $core]); + + self::assertSame(LogLevel::INFO, $logger->records[0]['level']); + self::assertSame('temporalio_sdk_core', $logger->records[0]['context']['target']); + } + + public function testRunningWorkerHeartbeatsAtTheConfiguredInterval(): void + { + $address = DevServer::address(); + $bridge = self::withServer([CoreEnvironment::WORKER_HEARTBEAT_INTERVAL => self::HEARTBEAT_INTERVAL_MS], static fn(): Bridge => new Bridge()); + $queue = \uniqid('core-heartbeat-', true); + $core = self::startWorker($bridge, $address, $queue); + + $statuses = self::heartbeats(ServiceClient::create($address), $queue); + $bridge->shutdownWorkers([1 => $core]); + + self::assertSame(\array_fill(0, self::HEARTBEATS, WorkerStatus::WORKER_STATUS_RUNNING), \array_values($statuses)); + } + + public function testOpenTelemetryEnvironmentExportsTaggedWorkerMetrics(): void + { + $collector = \stream_socket_server('tcp://127.0.0.1:0'); + $bridge = self::withServer([ + CoreEnvironment::OTEL_URL => 'http://' . (string) \stream_socket_get_name($collector, false) . '/v1/metrics', + CoreEnvironment::OTEL_PROTOCOL => 'http', + CoreEnvironment::OTEL_HEADERS => 'x-api-key = secret, x-tenant=php', + CoreEnvironment::OTEL_METRIC_PERIODICITY => self::OTEL_METRIC_PERIODICITY_MS, + CoreEnvironment::OTEL_USE_SECONDS_FOR_DURATIONS => 'true', + CoreEnvironment::METRIC_PREFIX => 'php_', + CoreEnvironment::METRIC_GLOBAL_TAGS => 'deployment=php-otlp', + ], static fn(): Bridge => new Bridge()); + $core = self::startWorker($bridge, DevServer::address(), \uniqid('core-otel-', true)); + + [$head, $body] = self::otlpRequest($collector, 'php_request'); + $bridge->shutdownWorkers([1 => $core]); + + self::assertStringStartsWith("POST /v1/metrics HTTP/1.1\r\n", $head); + self::assertStringContainsString("\r\nx-api-key: secret\r\n", \strtolower($head)); + self::assertStringContainsString("\r\nx-tenant: php\r\n", \strtolower($head)); + self::assertStringContainsString('php-otlp', $body); + } + + public function testPrometheusAndOpenTelemetryAreAlternatives(): void + { + $this->expectExceptionMessage('tpb_runtime_new failed: Prometheus and OpenTelemetry metrics cannot be used together'); + self::withServer([ + CoreEnvironment::PROMETHEUS => '127.0.0.1:0', + CoreEnvironment::OTEL_URL => 'http://127.0.0.1:4317', + ], static fn(): Bridge => new Bridge()); + } + + public function testCallToAClosedPortIsUnavailable(): void + { + $bridge = Bridge::shared(); + $client = $bridge->newClient(BridgeConnection::client(self::CLOSED_ADDRESS, null)); + + $result = $bridge->awaitCall($bridge->startCall($client, self::GET_SYSTEM_INFO, '', ['x-test' => ['1']], self::CALL_TIMEOUT_MS)); + $bridge->freeClient($client); + + self::assertSame(StatusCode::UNAVAILABLE, $result[0]); + } + + public function testCallToASilentServerTimesOut(): void + { + $server = \stream_socket_server('tcp://127.0.0.1:0'); + $bridge = Bridge::shared(); + $client = $bridge->newClient(BridgeConnection::client((string) \stream_socket_get_name($server, false), null)); + + $tag = $bridge->startCall($client, self::GET_SYSTEM_INFO, '', [], 50); + $pending = $bridge->pollCall($tag, 0); + $result = $bridge->awaitCall($tag); + $bridge->freeClient($client); + \fclose($server); + + self::assertNull($pending); + self::assertSame(StatusCode::DEADLINE_EXCEEDED, $result[0]); + } + + public function testConnectToAClosedPortIsUnavailable(): void + { + $bridge = Bridge::shared(); + $client = $bridge->newClient(BridgeConnection::client(self::CLOSED_ADDRESS, null)); + + $result = $bridge->pollCall($bridge->startConnect($client, self::CALL_TIMEOUT_MS), self::CALL_TIMEOUT_MS); + $bridge->freeClient($client); + + self::assertSame(StatusCode::UNAVAILABLE, $result[0] ?? null); + } + + public function testForgottenCallResultIsDropped(): void + { + $bridge = Bridge::shared(); + $client = $bridge->newClient(BridgeConnection::client(self::CLOSED_ADDRESS, null)); + + $tag = $bridge->startCall($client, self::GET_SYSTEM_INFO, '', [], self::CALL_TIMEOUT_MS); + $bridge->forgetCall($tag); + $result = $bridge->pollCall($tag, 1000); + $bridge->freeClient($client); + + self::assertNull($result); + } + + public function testWorkerCallsReportFailuresAsEvents(): void + { + $bridge = Bridge::shared(); + $core = Replayers::create($bridge, self::STARTED_EVENT_ID); + + $bridge->pollActivityTask($core, 7); + $bridge->completeWorkflowActivation($core, 8, 'x'); + $bridge->completeActivityTask($core, 9, 'x'); + $events = []; + while (\count($events) < 3) { + \array_push($events, ...$bridge->nextEvents(Bridge::POLL_TIMEOUT_MS)); + } + \usort($events, static fn(array $a, array $b): int => $a[0] <=> $b[0]); + + self::assertSame([7, Bridge::KIND_ACTIVITY_TASK, Bridge::STATUS_SHUTDOWN, ''], $events[0]); + self::assertSame([8, Bridge::KIND_WORKFLOW_COMPLETED], \array_slice($events[1], 0, 2)); + self::assertStringStartsWith('Decode failure', $events[1][3]); + self::assertSame([9, Bridge::KIND_ACTIVITY_COMPLETED], \array_slice($events[2], 0, 2)); + self::assertSame([], $bridge->shutdownWorkers([10 => $core])); + } + + public function testHeartbeatIsValidated(): void + { + $bridge = Bridge::shared(); + $core = Replayers::create($bridge, self::STARTED_EVENT_ID); + + $bridge->recordActivityHeartbeat($core, (new ActivityHeartbeat(['task_token' => 'unknown']))->serializeToString()); + try { + $this->expectExceptionMessage('Unable to record the activity heartbeat'); + $bridge->recordActivityHeartbeat($core, 'garbage'); + } finally { + $bridge->shutdownWorkers([1 => $core]); + } + } + + public function testShutdownEndsThePolls(): void + { + $bridge = Bridge::shared(); + $core = Replayers::create($bridge, self::STARTED_EVENT_ID); + + $bridge->initiateShutdown($core); + $bridge->initiateShutdown($core); + $bridge->pollWorkflowActivation($core, 1); + $events = []; + while ($events === []) { + $events = $bridge->nextEvents(Bridge::POLL_TIMEOUT_MS); + } + + self::assertSame([[1, Bridge::KIND_WORKFLOW_ACTIVATION, Bridge::STATUS_SHUTDOWN, '']], $events); + self::assertSame([], $bridge->shutdownWorkers([1 => $core])); + } + + public function testPeekedEventsAreReturnedAgain(): void + { + $bridge = Bridge::shared(); + $core = Replayers::create($bridge, self::STARTED_EVENT_ID); + + $bridge->completeWorkflowActivation($core, 1, 'x'); + $peeked = $bridge->peekEvents(); + $bridge->completeWorkflowActivation($core, 2, 'x'); + $events = $bridge->nextEvents(0); + $bridge->shutdownWorkers([3 => $core]); + + self::assertSame([1], \array_column($peeked, 0)); + self::assertSame([1, 2], \array_column($events, 0)); + } + + public function testOneFetchDrainsTheWholeQueue(): void + { + $bridge = Bridge::shared(); + $core = Replayers::create($bridge, self::STARTED_EVENT_ID); + + for ($i = 0; $i < self::MORE_EVENTS_THAN_ONE_FETCH; ++$i) { + $bridge->completeWorkflowActivation($core, 1, 'x'); + } + $events = $bridge->nextEvents(0); + $bridge->shutdownWorkers([2 => $core]); + + self::assertCount(self::MORE_EVENTS_THAN_ONE_FETCH, $events); + } + + private static function startWorker(Bridge $bridge, string $address, string $queue): \FFI\CData + { + $config = new CoreWorkerConfig(CoreOptions::create($address, null, null, null, null), new Marshaller(new AttributeMapperFactory(new AttributeReader()))); + $worker = CoreWorkerFactory::create()->newWorker($queue); + $core = $bridge->newWorker(['connection' => $config->connection($worker, '')] + $config->build($worker, CoreRole::Workflow)); + $bridge->pollWorkflowActivation($core, 1); + + return $core; + } + + /** + * @param resource $collector + * @return array{string, string} + */ + private static function otlpRequest($collector, string $metric): array + { + for ($i = 0; $i < self::OTLP_EXPORTS; ++$i) { + $connection = \stream_socket_accept($collector, self::OTLP_ACCEPT_TIMEOUT_SECONDS); + self::assertNotFalse($connection, 'No OTLP export arrived'); + $request = ''; + while (!\str_contains($request, self::HTTP_HEAD_END)) { + $request .= (string) \fread($connection, 8192); + } + [$head, $body] = \explode(self::HTTP_HEAD_END, $request, 2); + \preg_match('/^content-length: (\d+)$/mi', $head, $length); + while (\strlen($body) < (int) ($length[1] ?? 0)) { + $body .= (string) \fread($connection, 8192); + } + \fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + \fclose($connection); + if (\str_contains($body, $metric)) { + return [$head . self::HTTP_HEAD_END, $body]; + } + } + self::fail(\sprintf('No %s metric in %d OTLP exports', $metric, self::OTLP_EXPORTS)); + } + + /** + * @return array + */ + private static function heartbeats(ServiceClient $client, string $queue): array + { + $statuses = []; + $deadline = \microtime(true) + self::HEARTBEAT_WAIT_SECONDS; + while (\count($statuses) < self::HEARTBEATS && \microtime(true) < $deadline) { + foreach ($client->ListWorkers(new ListWorkersRequest(['namespace' => 'default']))->getWorkersInfo() as $info) { + $heartbeat = $info->getWorkerHeartbeat(); + if ($heartbeat?->getTaskQueue() === $queue) { + $statuses[(string) $heartbeat->getHeartbeatTime()?->serializeToString()] = $heartbeat->getStatus(); + } + } + \usleep(self::HEARTBEAT_CHECK_INTERVAL_US); + } + + return $statuses; + } + + /** + * @param array $server + */ + private static function withServer(array $server, \Closure $create): Bridge + { + $_SERVER = $server + $_SERVER; + try { + return $create(); + } finally { + foreach (\array_keys($server) as $name) { + unset($_SERVER[$name]); + } + } + } + +} diff --git a/tests/Core/Replayers.php b/tests/Core/Replayers.php new file mode 100644 index 000000000..d9d72e647 --- /dev/null +++ b/tests/Core/Replayers.php @@ -0,0 +1,38 @@ +build(CoreWorkerFactory::create()->newWorker('default'), CoreRole::Workflow); + } + + public static function create(Bridge $bridge, int $lastEventId, string $workflowId = 'replay'): \FFI\CData + { + return $bridge->newReplayer(self::config(), self::history($lastEventId)->serializeToString(), $workflowId); + } +} diff --git a/tests/Core/Testing/CoreWorkflowReplayerTestCase.php b/tests/Core/Testing/CoreWorkflowReplayerTestCase.php new file mode 100644 index 000000000..90592453e --- /dev/null +++ b/tests/Core/Testing/CoreWorkflowReplayerTestCase.php @@ -0,0 +1,172 @@ +expectNotToPerformAssertions(); + + self::replayer()->replayFromJSON(self::WORKFLOW_TYPE, new \SplFileInfo(self::FIXTURE), self::LAST_DETERMINISTIC_EVENT_ID); + } + + public function testFullDamagedHistoryIsNonDeterministic(): void + { + $this->expectException(NonDeterministicWorkflowException::class); + + self::replayer()->replayFromJSON(self::WORKFLOW_TYPE, self::FIXTURE); + } + + public function testWorkflowTypeThatIsNotRegisteredFailsTheReplay(): void + { + $this->expectException(ReplayerException::class); + $this->expectExceptionCode(13); + + self::replayer(workflow: SimpleWorkflow::class)->replayFromJSON(self::WORKFLOW_TYPE, self::FIXTURE); + } + + public function testMissingFileIsAnInvalidArgument(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('does not exist'); + + self::replayer()->replayFromJSON(self::WORKFLOW_TYPE, self::FIXTURE . '.missing'); + } + + public function testUnreadableFileIsAnInvalidArgument(): void + { + $file = \tempnam(\sys_get_temp_dir(), 'history'); + \chmod($file, 0); + \set_error_handler(static fn(): bool => true); + try { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be read'); + + self::replayer()->replayFromJSON(self::WORKFLOW_TYPE, $file); + } finally { + \restore_error_handler(); + \unlink($file); + } + } + + public function testHistoryObjectReplays(): void + { + $this->expectNotToPerformAssertions(); + + self::replayer()->replayHistory(self::deterministicHistory()); + } + + public function testEmptyHistoryIsRejected(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('History is empty or broken.'); + + self::replayer()->replayHistory(new History()); + } + + public function testServerHistoryNeedsAClient(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('A workflow client is required'); + + self::replayer()->replayFromServer(self::WORKFLOW_TYPE, new WorkflowExecution('id', 'run')); + } + + public function testDownloadNeedsAClient(): void + { + $this->expectException(\LogicException::class); + + self::replayer()->downloadHistory(self::WORKFLOW_TYPE, new WorkflowExecution('id', 'run'), \sys_get_temp_dir() . '/unused.json'); + } + + public function testServerHistoryReplays(): void + { + $execution = new WorkflowExecution('id', 'run'); + + self::replayer(self::client($execution))->replayFromServer(self::WORKFLOW_TYPE, $execution); + } + + public function testDownloadWritesTheHistoryAsJson(): void + { + $execution = new WorkflowExecution('id', 'run'); + $file = \tempnam(\sys_get_temp_dir(), 'history'); + try { + self::replayer(self::client($execution))->downloadHistory(self::WORKFLOW_TYPE, $execution, $file); + + $this->assertSame( + self::deterministicHistory()->serializeToString(), + HistoryJsonCodec::decode((string) \file_get_contents($file))->serializeToString(), + ); + } finally { + \unlink($file); + } + } + + public function testDownloadToAnUnwritablePathFails(): void + { + $execution = new WorkflowExecution('id', 'run'); + \set_error_handler(static fn(): bool => true); + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to write the history'); + + self::replayer(self::client($execution))->downloadHistory(self::WORKFLOW_TYPE, $execution, self::FIXTURE . '.missing/history.json'); + } finally { + \restore_error_handler(); + } + } + + private static function replayer(?WorkflowClientInterface $client = null, string $workflow = WorkflowWithSequence::class): CoreWorkflowReplayer + { + $factory = CoreWorkerFactory::create(logger: new NullLogger()); + $factory->newWorker()->registerWorkflowTypes($workflow); + + return new CoreWorkflowReplayer($factory, $client); + } + + private static function deterministicHistory(): History + { + return HistoryJsonCodec::decode((string) \file_get_contents(self::FIXTURE), self::LAST_DETERMINISTIC_EVENT_ID); + } + + private function client(WorkflowExecution $execution): WorkflowClientInterface + { + $response = new GetWorkflowExecutionHistoryResponse(['history' => self::deterministicHistory()]); + $client = $this->createMock(WorkflowClientInterface::class); + $client->expects($this->once()) + ->method('getWorkflowHistory') + ->with($execution) + ->willReturn(new WorkflowExecutionHistory(Paginator::createFromGenerator((static fn() => yield [$response])(), null))); + + return $client; + } +} diff --git a/tests/Core/Worker/ActivityTasksTestCase.php b/tests/Core/Worker/ActivityTasksTestCase.php new file mode 100644 index 000000000..d0f02c714 --- /dev/null +++ b/tests/Core/Worker/ActivityTasksTestCase.php @@ -0,0 +1,163 @@ +handle(static fn(): array => [], 'Act'); + + self::assertSame(self::TOKEN, $completion->getTaskToken()); + self::assertSame('Activity produced no result', $completion->getResult()->getFailed()->getFailure()->getMessage()); + } + + public function testDispatchErrorFailsOnlyItsTask(): void + { + $completion = $this->handle(static fn(): never => throw new \RuntimeException('dispatch broke'), 'Act'); + + self::assertSame(self::TOKEN, $completion->getTaskToken()); + self::assertSame('dispatch broke', $completion->getResult()->getFailed()->getFailure()->getMessage()); + } + + public function testSideEffectWithoutInputCompletesWithoutResult(): void + { + $completion = $this->handle(static fn(): array => [], ActivityTasks::SIDE_EFFECT); + + self::assertTrue($completion->getResult()->hasCompleted()); + self::assertNull($completion->getResult()->getCompleted()->getResult()); + } + + public function testSideEffectReturnsItsInput(): void + { + $completion = $this->handle(static fn(): array => [], ActivityTasks::SIDE_EFFECT, ['input' => [DataConverter::createDefault()->toPayload(7)]]); + + self::assertSame('7', $completion->getResult()->getCompleted()->getResult()->getData()); + } + + public function testResultAndHeartbeatDetailsAreDispatched(): void + { + $request = null; + $completion = $this->handle(static function (array $commands) use (&$request): array { + $request = $commands[0]; + return [new SuccessClientResponse($request->getID(), EncodedValues::fromValues(['done']))]; + }, 'Act', [ + 'input' => [DataConverter::createDefault()->toPayload('in')], + 'heartbeat_details' => [DataConverter::createDefault()->toPayload('beat')], + 'is_local' => true, + ]); + + self::assertInstanceOf(ServerRequest::class, $request); + self::assertSame(['InvokeLocalActivity', 1], [$request->getName(), $request->getOptions()['heartbeatDetails']]); + self::assertSame(['in', 'beat'], [$request->getPayloads()->getValue(0), $request->getPayloads()->getValue(1)]); + self::assertSame('"done"', $completion->getResult()->getCompleted()->getResult()->getData()); + } + + public function testAsyncCompletionIsReported(): void + { + $completion = $this->handle(static fn(array $commands): array => [new FailedClientResponse($commands[0]->getID(), new DoNotCompleteOnResultException())], 'Act'); + + self::assertTrue($completion->getResult()->hasWillCompleteAsync()); + } + + public function testUndecodableTaskWithoutTokenIsRethrown(): void + { + $this->expectException(\Exception::class); + self::tasks(static fn(): array => [])->handle(\FFI::cdef()->new('int'), 'queue', "\xff"); + } + + public function testHeartbeatOfAnUnknownTaskIsIgnored(): void + { + self::assertSame([], self::tasks(static fn(): array => [])->call('temporal.RecordActivityHeartbeat', self::heartbeat())); + } + + public function testOnlyHeartbeatsAreSupported(): void + { + $this->expectException(TransportException::class); + self::tasks(static fn(): array => [])->call('Other', []); + } + + public static function provideCancelReasons(): iterable + { + yield 'paused' => [ActivityCancelReason::PAUSED, ['paused' => true]]; + yield 'reset' => [ActivityCancelReason::RESET, ['reset' => true]]; + yield 'canceled' => [ActivityCancelReason::CANCELLED, ['canceled' => true]]; + } + + #[DataProvider('provideCancelReasons')] + public function testHeartbeatReportsTheCancelReason(int $reason, array $expected): void + { + $bridge = Bridge::shared(); + $worker = Replayers::create($bridge, self::STARTED_EVENT_ID); + $tasks = new ActivityTasks(); + $heartbeats = []; + $tasks->bind($bridge, static function (array $commands) use ($tasks, $worker, $reason, &$heartbeats): array { + $tasks->handle($worker, 'queue', (new ActivityTask(['task_token' => self::TOKEN, 'cancel' => new Cancel(['reason' => $reason])]))->serializeToString()); + $heartbeats[] = $tasks->call('temporal.RecordActivityHeartbeat', self::heartbeat()); + return [new FailedClientResponse($commands[0]->getID(), new CanceledFailure('canceled'))]; + }, DataConverter::createDefault()); + + $completion = self::completion($tasks->handle($worker, 'queue', self::start('Act'))); + $bridge->shutdownWorkers([1 => $worker]); + + self::assertSame([$expected], $heartbeats); + self::assertTrue($completion->getResult()->hasCancelled()); + } + + private function handle(\Closure $dispatch, string $type, array $start = []): ActivityTaskCompletion + { + return self::completion(self::tasks($dispatch)->handle(\FFI::cdef()->new('int'), 'queue', self::start($type, $start))); + } + + private static function tasks(\Closure $dispatch): ActivityTasks + { + $tasks = new ActivityTasks(); + $tasks->bind((new \ReflectionClass(Bridge::class))->newInstanceWithoutConstructor(), $dispatch, DataConverter::createDefault()); + + return $tasks; + } + + private static function start(string $type, array $start = []): string + { + return (new ActivityTask(['task_token' => self::TOKEN, 'start' => new Start(['activity_id' => '1', 'activity_type' => $type] + $start)]))->serializeToString(); + } + + private static function completion(?string $bytes): ActivityTaskCompletion + { + $completion = new ActivityTaskCompletion(); + $completion->mergeFromString((string) $bytes); + + return $completion; + } + + private static function heartbeat(): array + { + return ['taskToken' => \base64_encode(self::TOKEN), 'details' => \base64_encode((new Payloads())->serializeToString())]; + } + +} diff --git a/tests/Core/Worker/CoreReplayerTestCase.php b/tests/Core/Worker/CoreReplayerTestCase.php new file mode 100644 index 000000000..febb260c5 --- /dev/null +++ b/tests/Core/Worker/CoreReplayerTestCase.php @@ -0,0 +1,84 @@ +expectExceptionObject(new \InvalidArgumentException('The history has no events')); + CoreWorkerFactory::create()->replay(new History(), 'replay'); + } + + public function testHistoryOfAnUnknownTaskQueueIsRejected(): void + { + $this->expectException(\OutOfRangeException::class); + CoreWorkerFactory::create()->replay(Replayers::history(self::FULL_HISTORY), 'replay'); + } + + public function testReplayWithoutActivationTimesOut(): void + { + $replayer = new CoreReplayer(Bridge::shared(), self::SHORT_IDLE_TIMEOUT_SECONDS); + + try { + $replayer->replay(Replayers::history(self::STARTED_EVENT_ID), 'replay', Replayers::config(), self::activations(static fn(): array => [])); + self::fail('The replay must time out'); + } catch (ReplayFailedException $e) { + self::assertSame('The replay got no activation for 1 seconds', $e->getMessage()); + self::assertFalse($e->nonDeterministic); + } + } + + public function testRejectedCompletionFailsTheReplayAndForeignEventsAreSkipped(): void + { + $bridge = Bridge::shared(); + $other = $bridge->newReplayer(Replayers::config(), Replayers::history(self::STARTED_EVENT_ID)->serializeToString(), 'other'); + $replayTag = (int) (new \ReflectionClass(CoreReplayer::class))->getStaticPropertyValue('tag') + 1; + $evicted = false; + $activations = self::activations(static function (array $messages) use ($bridge, $other, $replayTag, &$evicted): array { + if ($messages[0]->getName() === 'StartWorkflow') { + $bridge->completeActivityTask($other, $replayTag, 'x'); + $bridge->completeWorkflowActivation($other, $replayTag + 1, 'x'); + } + if ($messages[0]->getName() === 'DestroyWorkflow') { + $evicted = true; + $bridge->completeWorkflowActivation($other, $replayTag, 'x'); + } + return []; + }); + + try { + (new CoreReplayer($bridge))->replay(Replayers::history(self::FIRST_TASK_EVENT_ID), 'replay', Replayers::config(), $activations); + self::fail('The rejected completion must fail the replay'); + } catch (\RuntimeException $e) { + self::assertStringStartsWith('sdk-core completion failed: Decode failure', $e->getMessage()); + } finally { + $bridge->shutdownWorkers([$replayTag + 1 => $other]); + } + self::assertTrue($evicted); + } + + + + private static function activations(\Closure $dispatch): WorkflowActivations + { + return new WorkflowActivations(DataConverter::createDefault(), $dispatch, 'default', 'default', [], false); + } +} diff --git a/tests/Core/Worker/CoreWorkerFactoryTestCase.php b/tests/Core/Worker/CoreWorkerFactoryTestCase.php new file mode 100644 index 000000000..e3b9c2086 --- /dev/null +++ b/tests/Core/Worker/CoreWorkerFactoryTestCase.php @@ -0,0 +1,288 @@ +> */ + public static array $heartbeatErrors = []; + + public function testOnlyActivityTasksCanBeTheRpcConnection(): void + { + $this->expectException(\InvalidArgumentException::class); + CoreWorkerFactory::create(rpc: $this->createStub(RPCConnectionInterface::class)); + } + + public function testHostConnectionIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + CoreWorkerFactory::create()->run($this->createStub(HostConnectionInterface::class)); + } + + public function testChildRoleWithoutWorkReturns(): void + { + $logger = new LoggerSpy(); + $_SERVER[CoreEnvironment::ROLE] = 'activity'; + try { + $code = CoreWorkerFactory::create(logger: $logger)->run(); + } finally { + unset($_SERVER[CoreEnvironment::ROLE]); + } + + self::assertSame(0, $code); + self::assertSame('No task queue needs a activity process, exiting', $logger->records[0]['message']); + } + + public function testSingleProcessWithoutWorkReturns(): void + { + $logger = new LoggerSpy(); + + $code = CoreWorkerFactory::create(logger: $logger)->run(); + + self::assertSame(0, $code); + self::assertSame('No task queue needs a all process, exiting', $logger->records[0]['message']); + } + + public function testWorkerRunsWorkflowsAndActivities(): void + { + $queue = self::startWorkflow(); + $factory = CoreWorkerFactory::create(address: self::$address, logger: new LoggerSpy()); + $factory->newWorker($queue) + ->registerWorkflowTypes(CoreHeartbeatWorkflow::class) + ->registerActivityImplementations(new CoreHeartbeatActivity()); + $wire = []; + $factory->observeWire(static function (string $type, string $bytes) use (&$wire): void { + (new $type())->mergeFromString($bytes); + $wire[$type] = true; + }); + + self::assertSame(0, self::runWithTimeout($factory)); + self::assertSame([ActivityPausedException::class], self::$heartbeatErrors); + self::assertEqualsCanonicalizing( + [WorkflowActivation::class, WorkflowActivationCompletion::class, ActivityTask::class, ActivityTaskCompletion::class], + \array_keys($wire), + ); + } + + public function testResourceBasedTunerRunsWorkflowsAndActivities(): void + { + $queue = self::startWorkflow(); + $tuner = [CoreEnvironment::TUNER_TARGET_MEMORY_USAGE => '0.9', CoreEnvironment::TUNER_TARGET_CPU_USAGE => '0.9']; + $_SERVER = $tuner + $_SERVER; + try { + $factory = CoreWorkerFactory::create(address: self::$address, logger: new LoggerSpy()); + } finally { + foreach (\array_keys($tuner) as $name) { + unset($_SERVER[$name]); + } + } + $factory->newWorker($queue) + ->registerWorkflowTypes(CoreHeartbeatWorkflow::class) + ->registerActivityImplementations(new CoreHeartbeatActivity()); + + self::assertSame(0, self::runWithTimeout($factory)); + self::assertSame([ActivityPausedException::class], self::$heartbeatErrors); + self::assertSame(['ResourceBased', 'ResourceBased', 'Fixed'], self::slotSupplierKinds($queue)); + } + + public function testFailedWorkerCreationReleasesTheEarlierTaskQueues(): void + { + $queue = self::startWorkflow(); + $failing = CoreWorkerFactory::create(address: self::$address, logger: new LoggerSpy()); + $failing->newWorker($queue)->registerWorkflowTypes(CoreHeartbeatWorkflow::class); + $failing->newWorker($queue . '-invalid', WorkerOptions::new()->withDeploymentOptions(WorkerDeploymentOptions::new() + ->withUseVersioning(false) + ->withVersion('core.invalid') + ->withDefaultVersioningBehavior(VersioningBehavior::Pinned))) + ->registerWorkflowTypes(CoreHeartbeatWorkflow::class); + try { + $failing->run(); + self::fail('The invalid deployment options must fail the worker creation'); + } catch (\RuntimeException $e) { + self::assertStringContainsString('default_versioning_behavior', $e->getMessage()); + } + + $factory = CoreWorkerFactory::create(address: self::$address, logger: new LoggerSpy()); + $factory->newWorker($queue) + ->registerWorkflowTypes(CoreHeartbeatWorkflow::class) + ->registerActivityImplementations(new CoreHeartbeatActivity()); + + self::assertSame(0, self::runWithTimeout($factory)); + } + + public function testActivityProcessRunsTheActivitiesOfTheWorkflowProcess(): void + { + $queue = self::startWorkflow(); + $workflows = CoreWorkerFactory::create(address: self::$address, logger: new LoggerSpy()); + $workflows->newWorker($queue, WorkerOptions::new()->withLocalActivityWorkerOnly(true))->registerWorkflowTypes(CoreHeartbeatWorkflow::class); + self::$stopInWorkflow = true; + try { + self::assertSame(0, self::runWithTimeout($workflows)); + } finally { + self::$stopInWorkflow = false; + } + + $activities = CoreWorkerFactory::create(address: self::$address, logger: new LoggerSpy()); + $activities->newWorker($queue)->registerActivityImplementations(new CoreHeartbeatActivity()); + + $_SERVER[CoreEnvironment::ROLE] = 'activity'; + try { + self::assertSame(0, self::runWithTimeout($activities)); + } finally { + unset($_SERVER[CoreEnvironment::ROLE]); + } + self::assertSame([ActivityPausedException::class], self::$heartbeatErrors); + } + + public function testUpdatedApiKeyIsReadAgainWhileTheWorkerRuns(): void + { + $queue = self::startWorkflow(); + $factory = CoreWorkerFactory::create(address: self::$address, logger: new LoggerSpy()); + $factory->newWorker($queue, WorkerOptions::new()->withLocalActivityWorkerOnly(true))->registerWorkflowTypes(CoreHeartbeatWorkflow::class); + $key = new class implements \Stringable { + public int $reads = 0; + + public function __toString(): string + { + return 'key-' . ++$this->reads; + } + }; + $factory->updateApiKey($key); + self::$stopInWorkflow = true; + try { + self::assertSame(0, self::runWithTimeout($factory)); + } finally { + self::$stopInWorkflow = false; + } + + self::assertGreaterThan(2, $key->reads); + } + + private static function startWorkflow(): string + { + self::$address = DevServer::address(); + self::$heartbeatErrors = []; + $queue = \uniqid('core-worker-', true); + $client = WorkflowClient::create(ServiceClient::create(self::$address)); + $client->start($client->newUntypedWorkflowStub('CoreHeartbeatWorkflow', WorkflowOptions::new()->withTaskQueue($queue))); + + return $queue; + } + + /** + * @return list + */ + private static function slotSupplierKinds(string $queue): array + { + $workers = ServiceClient::create(self::$address)->ListWorkers(new ListWorkersRequest(['namespace' => 'default'])); + foreach ($workers->getWorkersInfo() as $info) { + $heartbeat = $info->getWorkerHeartbeat(); + if ($heartbeat?->getTaskQueue() === $queue) { + return [ + (string) $heartbeat->getWorkflowTaskSlotsInfo()?->getSlotSupplierKind(), + (string) $heartbeat->getActivityTaskSlotsInfo()?->getSlotSupplierKind(), + (string) $heartbeat->getLocalActivitySlotsInfo()?->getSlotSupplierKind(), + ]; + } + } + + return []; + } + + private static function runWithTimeout(CoreWorkerFactory $factory): int + { + \pcntl_signal(\SIGALRM, static fn() => \posix_kill(\getmypid(), \SIGTERM)); + \pcntl_alarm(self::SAFETY_TIMEOUT_SECONDS); + try { + return $factory->run(); + } finally { + \pcntl_alarm(0); + \pcntl_signal(\SIGALRM, \SIG_DFL); + } + } +} + +#[WorkflowInterface] +final class CoreHeartbeatWorkflow +{ + #[WorkflowMethod(name: 'CoreHeartbeatWorkflow')] + public function run(): \Generator + { + if (CoreWorkerFactoryTestCase::$stopInWorkflow) { + \posix_kill(\getmypid(), \SIGTERM); + } + + return yield Workflow::newActivityStub(CoreHeartbeatActivity::class, ActivityOptions::new() + ->withStartToCloseTimeout(30) + ->withHeartbeatTimeout(2) + ->withRetryOptions(RetryOptions::new()->withMaximumAttempts(1)))->beat(); + } +} + +#[ActivityInterface(prefix: 'CoreHeartbeat.')] +final class CoreHeartbeatActivity +{ + private const HEARTBEATS = 100; + private const HEARTBEAT_INTERVAL_US = 100_000; + + #[ActivityMethod] + public function beat(): string + { + $info = Activity::getInfo(); + ServiceClient::create(CoreWorkerFactoryTestCase::$address)->PauseActivity(new PauseActivityRequest([ + 'namespace' => $info->workflowNamespace, + 'execution' => new WorkflowExecution(['workflow_id' => $info->workflowExecution->getID()]), + 'id' => $info->id, + ])); + try { + for ($i = 0; $i < self::HEARTBEATS; ++$i) { + Activity::heartbeat($i); + \usleep(self::HEARTBEAT_INTERVAL_US); + } + } catch (\Throwable $e) { + CoreWorkerFactoryTestCase::$heartbeatErrors[] = $e::class; + throw $e; + } finally { + \posix_kill(\getmypid(), \SIGTERM); + } + + return 'not paused'; + } +} diff --git a/tests/Core/Worker/CoreWorkerLoopTestCase.php b/tests/Core/Worker/CoreWorkerLoopTestCase.php new file mode 100644 index 000000000..1ce04b248 --- /dev/null +++ b/tests/Core/Worker/CoreWorkerLoopTestCase.php @@ -0,0 +1,174 @@ +logger = new LoggerSpy(); + $this->bridge = Bridge::shared(); + $this->bridge->useLogger(new NullLogger()); + } + + public function testEndedReplayCrashesTheProcess(): void + { + $code = $this->serve(self::handle(static fn(): array => [])); + + self::assertSame(1, $code); + self::assertSame([self::SHUT_DOWN], $this->errors()); + } + + public function testNoWorkersMeansNothingToServe(): void + { + self::assertSame(0, $this->loop()->serve(static fn(): array => [])); + } + + public function testStopSignalShutsDownCleanly(): void + { + $code = $this->serve(self::handle(static function (): array { + \posix_kill(\getmypid(), \SIGTERM); + return []; + })); + + self::assertSame(0, $code); + self::assertSame([], $this->errors()); + } + + public function testStopBeforePollingShutsDownCleanly(): void + { + $code = $this->loop()->serve(function (): array { + \posix_kill(\getmypid(), \SIGTERM); + return [self::handle(static fn(): array => [])]; + }); + + self::assertSame(0, $code); + self::assertSame([], $this->errors()); + } + + public function testRejectedCompletionIsLogged(): void + { + $core = null; + $handle = self::handle(function () use (&$core): array { + $this->bridge->completeWorkflowActivation($core, 0, 'x'); + return []; + }); + $core = $handle->core; + + $this->serve($handle); + + self::assertStringStartsWith('sdk-core completion failed: Decode failure', $this->errors()[0]); + } + + public function testGoneSupervisorStopsTheProcess(): void + { + $code = $this->loop(supervisorPid: \posix_getppid() + 1)->serve(static fn(): array => [self::handle(static fn(): array => [])]); + + self::assertSame(0, $code); + self::assertSame(['The supervisor process is gone, stopping'], $this->errors()); + } + + public function testFailedActivityPollCrashesTheProcess(): void + { + $code = $this->serve(self::handle(static fn(): array => [], workflows: false, activities: true)); + + self::assertSame(1, $code); + self::assertSame([self::SHUT_DOWN], $this->errors()); + } + + public function testWorkerWithAnOutstandingActivationIsReported(): void + { + $handle = self::handle(static fn(): array => [], workflows: false); + $this->bridge->pollWorkflowActivation($handle->core, self::FOREIGN_TAG); + while ($this->bridge->nextEvents(Bridge::POLL_TIMEOUT_MS) === []); + $this->bridge->completeActivityTask($handle->core, 0, 'x'); + + $code = $this->serve($handle); + + self::assertSame(0, $code); + self::assertMatchesRegularExpression('/^sdk-core worker for task queue "default" did not finalize: Decode failure.*; The sdk-core worker did not finalize in time$/', $this->errors()[0]); + } + + public function testUnchangedApiKeyIsReadAgainButNotSet(): void + { + $reads = 0; + $code = $this->loop(apiKey: static function () use (&$reads): string { + ++$reads; + return 'key'; + })->serve(static fn(): array => [self::handle(static fn(): array => [])]); + + self::assertSame(1, $code); + self::assertGreaterThan(1, $reads); + } + + public function testChangedApiKeyIsSetOnTheWorkers(): void + { + $keys = ['old', 'new']; + $handle = self::handle(static fn(): array => []); + try { + $this->loop(apiKey: static function () use (&$keys): string { + return \count($keys) > 1 ? \array_shift($keys) : $keys[0]; + })->serve(static fn(): array => [$handle]); + } catch (\RuntimeException $e) { + $this->bridge->shutdownWorkers([$handle->core]); + self::assertSame('Unable to set the API key: the sdk-core worker has no connection or is finalized', $e->getMessage()); + return; + } + self::fail('A replayer has no connection for the API key'); + } + + private function serve(CoreWorkerHandle $handle): int + { + return $this->loop()->serve(static fn(): array => [$handle]); + } + + /** + * @param null|\Closure(): string $apiKey + */ + private function loop(?int $supervisorPid = null, ?\Closure $apiKey = null): CoreWorkerLoop + { + $activityTasks = new ActivityTasks(); + $activityTasks->bind($this->bridge, static fn(): array => [], DataConverter::createDefault()); + + return new CoreWorkerLoop($this->bridge, $activityTasks, $this->logger, $supervisorPid, $apiKey ?? static fn(): string => ''); + } + + /** + * @return list + */ + private function errors(): array + { + return \array_column($this->logger->records, 'message'); + } + + private static function handle(\Closure $dispatch, bool $workflows = true, bool $activities = false): CoreWorkerHandle + { + return new CoreWorkerHandle( + Replayers::create(Bridge::shared(), self::FIRST_TASK_EVENT_ID), + 'default', + new WorkflowActivations(DataConverter::createDefault(), $dispatch, 'default', 'default', [], false), + $workflows, + $activities, + ); + } +} diff --git a/tests/Core/Worker/CoreWorkflowCacheTestCase.php b/tests/Core/Worker/CoreWorkflowCacheTestCase.php new file mode 100644 index 000000000..0d3178555 --- /dev/null +++ b/tests/Core/Worker/CoreWorkflowCacheTestCase.php @@ -0,0 +1,103 @@ +start($client->newUntypedWorkflowStub('CoreCachedWorkflow', WorkflowOptions::new() + ->withTaskQueue($queue) + ->withWorkflowExecutionTimeout(self::WORKFLOW_TIMEOUT_SECONDS))); + } + + [$code, $output] = self::runWorker($address, $queue); + + self::assertSame(0, $code, $output); + self::assertSame(1, \preg_match('/^started (\d+), peak (\d+)$/m', $output, $result), $output); + self::assertSame(self::WORKFLOWS, (int) $result[1]); + self::assertLessThan(self::MEMORY_LIMIT_BYTES, (int) $result[2]); + } + + /** + * @return array{int, string} + */ + private static function runWorker(string $address, string $queue): array + { + $script = \tempnam(\sys_get_temp_dir(), 'core-cache-worker'); + \file_put_contents($script, self::workerScript()); + try { + $process = \proc_open( + [\PHP_BINARY, '-d', 'memory_limit=' . self::MEMORY_LIMIT, $script, $address, $queue, (string) self::WORKFLOWS, (string) self::SAFETY_TIMEOUT_SECONDS], + [1 => ['pipe', 'w'], 2 => ['redirect', 1]], + $pipes, + ); + self::assertIsResource($process); + $output = (string) \stream_get_contents($pipes[1]); + + return [\proc_close($process), $output]; + } finally { + \unlink($script); + } + } + + private static function workerScript(): string + { + $autoload = \var_export(\dirname(__DIR__, 3) . '/vendor/autoload.php', true); + + return << false); + } + } + + \$factory = CoreWorkerFactory::create(address: \$argv[1]); + \$factory->newWorker(\$argv[2])->registerWorkflowTypes(CoreCachedWorkflow::class); + \\pcntl_signal(\\SIGALRM, static fn() => \\posix_kill(\\getmypid(), \\SIGTERM)); + \\pcntl_alarm((int) \$argv[4]); + \$code = \$factory->run(); + echo 'started ', CoreCachedWorkflow::\$started, ', peak ', \\memory_get_peak_usage(), \\PHP_EOL; + exit(\$code); + PHP; + } +} diff --git a/tests/CoreWorker.php b/tests/CoreWorker.php new file mode 100644 index 000000000..054ae9d7e --- /dev/null +++ b/tests/CoreWorker.php @@ -0,0 +1,39 @@ + $command + * @param array $env + */ + public static function start(array $command, string $cwd, string $log, array $env): Process + { + @\mkdir(\dirname($log), recursive: true); + $process = Process::fromShellCommandline( + \sprintf('exec %s >> %s 2>&1', \implode(' ', \array_map(\escapeshellarg(...), $command)), \escapeshellarg($log)), + $cwd, + $env, + timeout: null, + ); + $process->start(); + \usleep(self::STARTUP_CHECK_MICROSECONDS); + if (!$process->isRunning()) { + throw new \RuntimeException("The core worker exited, see $log"); + } + + return $process; + } +} diff --git a/tests/Fixtures/WorkerMock.php b/tests/Fixtures/WorkerMock.php index 14772eebe..6610749fa 100644 --- a/tests/Fixtures/WorkerMock.php +++ b/tests/Fixtures/WorkerMock.php @@ -11,6 +11,7 @@ namespace Temporal\Tests\Fixtures; +use Temporal\Api\Common\V1\Payloads; use Temporal\Api\Failure\V1\Failure; use Temporal\DataConverter\DataConverter; use Temporal\Exception\Failure\FailureConverter; @@ -131,7 +132,7 @@ public function send(string $frame): void } } - $this->testCase->assertEquals($pair[0], $frame); + $this->testCase->assertEquals($this->decodePayloads($pair[0]), $this->decodePayloads($frame)); $this->indexIn++; } @@ -144,6 +145,21 @@ public function error(\Throwable $error): void throw $error; } + private function decodePayloads(string $frame): array + { + $commands = \json_decode($frame, true); + + foreach ($commands as $i => $command) { + if (isset($command['payloads'])) { + $payloads = new Payloads(); + $payloads->mergeFromString(\base64_decode($command['payloads'])); + $commands[$i]['payloads'] = \json_decode($payloads->serializeToJsonString(), true); + } + } + + return $commands; + } + private function getClasses(string $dir): iterable { $files = \glob($dir . '/*.php'); diff --git a/tests/Fixtures/src/Activity/SimpleActivity.php b/tests/Fixtures/src/Activity/SimpleActivity.php index 27ba075e1..2d1217cb5 100644 --- a/tests/Fixtures/src/Activity/SimpleActivity.php +++ b/tests/Fixtures/src/Activity/SimpleActivity.php @@ -98,6 +98,7 @@ public function external() ); } + #[ActivityMethod] public function updateRunID(WorkflowExecution $e): WorkflowExecution { $e->setRunId('updated'); diff --git a/tests/Fixtures/src/Workflow/NamedArguments/ExecuteChildNamedArgumentsWorkflow.php b/tests/Fixtures/src/Workflow/NamedArguments/ExecuteChildNamedArgumentsWorkflow.php index 7dd86ee11..a26aa0dbe 100644 --- a/tests/Fixtures/src/Workflow/NamedArguments/ExecuteChildNamedArgumentsWorkflow.php +++ b/tests/Fixtures/src/Workflow/NamedArguments/ExecuteChildNamedArgumentsWorkflow.php @@ -16,38 +16,26 @@ public function handler( ?string $nullableString = null, array $array = [], ): \Generator|array { - $oneParamRes = yield Workflow::executeChildWorkflow( - 'SimpleNamedArgumentsWorkflow', - [ - 'int' => $int, - ] + $oneParamRes = yield Workflow::newChildWorkflowStub(SimpleNamedArgumentsWorkflow::class)->handler( + int: $int, ); - $paramsInDifferentOrderRes = yield Workflow::executeChildWorkflow( - 'SimpleNamedArgumentsWorkflow', - [ - 'string' => $string, - 'int' => $int, - 'bool' => $bool, - 'nullableString' => $nullableString, - 'array' => $array, - ] + $paramsInDifferentOrderRes = yield Workflow::newChildWorkflowStub(SimpleNamedArgumentsWorkflow::class)->handler( + string: $string, + int: $int, + bool: $bool, + nullableString: $nullableString, + array: $array, ); - $missingParamsRes = yield Workflow::executeChildWorkflow( - 'SimpleNamedArgumentsWorkflow', - [ - 'int' => $int, - 'nullableString' => $nullableString, - ] + $missingParamsRes = yield Workflow::newChildWorkflowStub(SimpleNamedArgumentsWorkflow::class)->handler( + int: $int, + nullableString: $nullableString, ); - $missingParamAndDifferentOrderRes = yield Workflow::executeChildWorkflow( - 'SimpleNamedArgumentsWorkflow', - [ - 'nullableString' => $nullableString, - 'int' => $int, - ] + $missingParamAndDifferentOrderRes = yield Workflow::newChildWorkflowStub(SimpleNamedArgumentsWorkflow::class)->handler( + nullableString: $nullableString, + int: $int, ); return [ diff --git a/tests/Functional/.rr.core.yaml b/tests/Functional/.rr.core.yaml new file mode 100644 index 000000000..cf051563a --- /dev/null +++ b/tests/Functional/.rr.core.yaml @@ -0,0 +1,28 @@ +version: "3" +rpc: + listen: ${ROADRUNNER_ADDRESS:-tcp://127.0.0.1:6001} + +service: + workflow: + command: "php worker.php" + process_num: 1 + remain_after_exit: true + restart_sec: 1 + env: + TEMPORAL_CORE_ROLE: workflow + activity: + command: "php worker.php" + process_num: 1 + remain_after_exit: true + restart_sec: 1 + env: + TEMPORAL_CORE_ROLE: activity + +kv: + test: + driver: memory + config: + interval: 10 + +logs: + mode: raw diff --git a/tests/Functional/Client/AwaitTestCase.php b/tests/Functional/Client/AwaitTestCase.php index 212be6ab9..944f73595 100644 --- a/tests/Functional/Client/AwaitTestCase.php +++ b/tests/Functional/Client/AwaitTestCase.php @@ -11,7 +11,9 @@ namespace Temporal\Tests\Functional\Client; +use Temporal\Api\Enums\V1\EventType; use Temporal\DataConverter\Type; +use Temporal\Exception\Client\TimeoutException; use Temporal\Exception\Client\WorkflowFailedException; use Temporal\Exception\Failure\ActivityFailure; use Temporal\Exception\Failure\ApplicationFailure; @@ -56,6 +58,16 @@ public function testAggregated(): void $wait->addValue('test3'); $wait->addValue('test4'); + try { + $result = $run->getResult(Type::TYPE_ARRAY, 10); + } catch (TimeoutException) { + $events = []; + foreach ($client->getWorkflowHistory($run->getExecution()) as $event) { + $events[] = EventType::name($event->getEventType()); + } + $this->fail('No close event in 10 seconds, history: ' . \implode(', ', $events)); + } + $this->assertSame( [ 'test1', @@ -63,7 +75,7 @@ public function testAggregated(): void 'test3', 'test4' ], - $run->getResult(Type::TYPE_ARRAY, 3) + $result, ); } diff --git a/tests/Functional/Client/FailureTestCase.php b/tests/Functional/Client/FailureTestCase.php index f9430b9b0..61fd33992 100644 --- a/tests/Functional/Client/FailureTestCase.php +++ b/tests/Functional/Client/FailureTestCase.php @@ -20,6 +20,7 @@ use Temporal\Exception\Failure\ActivityFailure; use Temporal\Exception\Failure\ApplicationFailure; use Temporal\Exception\Failure\ChildWorkflowFailure; +use Temporal\Testing\WithoutTimeSkipping; use Temporal\Tests\Workflow\SignalExceptionsWorkflow; use Temporal\Tests\Workflow\UpdateExceptionsWorkflow; @@ -29,6 +30,8 @@ */ class FailureTestCase extends AbstractClient { + use WithoutTimeSkipping; + public function testSimpleFailurePropagation() { $client = $this->createClient(); diff --git a/tests/Functional/Client/UuidTestCase.php b/tests/Functional/Client/UuidTestCase.php index ac7ab79c4..f7f6933c4 100644 --- a/tests/Functional/Client/UuidTestCase.php +++ b/tests/Functional/Client/UuidTestCase.php @@ -13,7 +13,6 @@ use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; -use Temporal\Testing\Replay\WorkflowReplayer; use Temporal\Tests\Workflow\SimpleUuidWorkflow; /** @@ -43,7 +42,7 @@ public function testSideEffectWithUuid(): void $run = $client->start($workflow, $uuid); $result = $run->getResult(UuidInterface::class); - (new WorkflowReplayer())->replayFromServer( + $this->createReplayer($client, SimpleUuidWorkflow::class)->replayFromServer( 'SimpleUuidWorkflow', $run->getExecution(), ); diff --git a/tests/Functional/ClientSideDelayedCallbackTestCase.php b/tests/Functional/ClientSideDelayedCallbackTestCase.php index 6e6a6585f..09b33d8f2 100644 --- a/tests/Functional/ClientSideDelayedCallbackTestCase.php +++ b/tests/Functional/ClientSideDelayedCallbackTestCase.php @@ -35,7 +35,9 @@ public function testCallbacksFireAtScheduledSimulatedTimes(): void self::assertCount(2, $events); self::assertSame('first', $events[0]['value']); self::assertSame('second', $events[1]['value']); - self::assertSame(300, $events[1]['at'] - $events[0]['at']); + $gap = $events[1]['at'] - $events[0]['at']; + self::assertGreaterThanOrEqual(300, $gap); + self::assertLessThan(305, $gap); self::assertGreaterThanOrEqual(300, $events[0]['at'] - $base); self::assertLessThan(20, $elapsed); } diff --git a/tests/Functional/NamedArgumentsTestCase.php b/tests/Functional/NamedArgumentsTestCase.php index 081532642..2e8d43bd2 100644 --- a/tests/Functional/NamedArgumentsTestCase.php +++ b/tests/Functional/NamedArgumentsTestCase.php @@ -227,10 +227,6 @@ public function testContinueAsNewTaskQueue(): void public function testExecuteChildNamedArguments(): void { - $this->markTestSkipped( - 'Workflow::executeChildWorkflow executes child as untyped, so named arguments are not supported', - ); - $workflow = $this->workflowClient->newWorkflowStub( ExecuteChildNamedArgumentsWorkflow::class, ); diff --git a/tests/Functional/ReplayerTestCase.php b/tests/Functional/ReplayerTestCase.php index 2ab06c1e3..94670a424 100644 --- a/tests/Functional/ReplayerTestCase.php +++ b/tests/Functional/ReplayerTestCase.php @@ -14,7 +14,6 @@ use Temporal\Client\WorkflowStubInterface; use Temporal\Testing\Replay\Exception\NonDeterministicWorkflowException; use Temporal\Testing\Replay\Exception\ReplayerException; -use Temporal\Testing\Replay\WorkflowReplayer; use Temporal\Testing\TemporalServer; use Temporal\Tests\TestCase; use Temporal\Tests\Workflow\AwaitsUpdateWorkflow; @@ -46,7 +45,7 @@ public function testReplayWorkflowFromServer(): void $run = $this->workflowClient->start($workflow, 'hello'); $run->getResult('string'); - (new WorkflowReplayer())->replayFromServer( + $this->createReplayer($this->workflowClient, WorkflowWithSequence::class)->replayFromServer( 'WorkflowWithSequence', $run->getExecution(), ); @@ -70,7 +69,7 @@ public function testReplayWorkflowFromServerWithUpdates(): void $stub->signal('exit'); $stub->getResult(); - (new WorkflowReplayer())->replayFromServer( + $this->createReplayer($this->workflowClient, AwaitsUpdateWorkflow::class)->replayFromServer( 'AwaitsUpdate.greet', $stub->getExecution(), ); @@ -91,10 +90,11 @@ public function testReplayWorkflowFromFile(): void \unlink($file); } - (new WorkflowReplayer())->downloadHistory('WorkflowWithSequence', $run->getExecution(), $file); + $replayer = $this->createReplayer($this->workflowClient, WorkflowWithSequence::class); + $replayer->downloadHistory('WorkflowWithSequence', $run->getExecution(), $file); $this->assertFileExists($file); - (new WorkflowReplayer())->replayFromJSON('WorkflowWithSequence', $file); + $replayer->replayFromJSON('WorkflowWithSequence', $file); } finally { if (\is_file($file)) { \unlink($file); @@ -108,14 +108,14 @@ public function testReplayNonDetermenisticWorkflow(): void $this->expectException(NonDeterministicWorkflowException::class); - (new WorkflowReplayer())->replayFromJSON('WorkflowWithSequence', $file); + $this->createReplayer($this->workflowClient, WorkflowWithSequence::class)->replayFromJSON('WorkflowWithSequence', $file); } public function testReplayNonDetermenisticWorkflowThroughFirstDetermenisticEvents(): void { $file = \dirname(__DIR__, 1) . '/Fixtures/history/squence-workflow-damaged.json'; - (new WorkflowReplayer())->replayFromJSON('WorkflowWithSequence', $file, lastEventId: 11); + $this->createReplayer($this->workflowClient, WorkflowWithSequence::class)->replayFromJSON('WorkflowWithSequence', $file, lastEventId: 11); $this->assertTrue(true); } @@ -126,7 +126,7 @@ public function testReplayUnexistingFile(): void $this->expectException(ReplayerException::class); - (new WorkflowReplayer())->replayFromJSON('WorkflowWithSequence', $file); + $this->createReplayer($this->workflowClient, WorkflowWithSequence::class)->replayFromJSON('WorkflowWithSequence', $file); } /** @@ -187,7 +187,7 @@ public function testReplayWorkflowHistory(): void $this->assertGreaterThan(10, \count(\iterator_to_array($history->getEvents(), false))); - (new WorkflowReplayer())->replayHistory($history); + $this->createReplayer($this->workflowClient, SignalWorkflow::class)->replayHistory($history); } /** @@ -215,7 +215,7 @@ public function testReplayBrokenWorkflowHistory(): void $this->assertGreaterThan(10, \count(\iterator_to_array($history->getEvents(), false))); - (new WorkflowReplayer())->replayHistory($history); + $this->createReplayer($this->workflowClient, SignalWorkflow::class)->replayHistory($history); // Broke the history and replay it again. /** @var HistoryEvent $event */ @@ -230,7 +230,7 @@ public function testReplayBrokenWorkflowHistory(): void } $this->expectException(NonDeterministicWorkflowException::class); - (new WorkflowReplayer())->replayHistory($history); + $this->createReplayer($this->workflowClient, SignalWorkflow::class)->replayHistory($history); } /** @@ -294,7 +294,7 @@ public function testReplayInvalidHistory(): void { $this->expectException(\LogicException::class); - (new WorkflowReplayer())->replayHistory(new History()); + $this->createReplayer($this->workflowClient, WorkflowWithSequence::class)->replayHistory(new History()); } /** @@ -325,7 +325,7 @@ public function testReplayNotExistingWorkflowHistory(): void ->setName('nonExistingWorkflowTypeTestFooBar123'); $this->expectException(ReplayerException::class); - (new WorkflowReplayer())->replayHistory($history); + $this->createReplayer($this->workflowClient, SignalWorkflow::class)->replayHistory($history); } private function createAwaitsUpdateUntypedStub(WorkflowClient $client): WorkflowStubInterface diff --git a/tests/Functional/SimpleWorkflowTestCase.php b/tests/Functional/SimpleWorkflowTestCase.php index bd642f544..8c1f6de5c 100644 --- a/tests/Functional/SimpleWorkflowTestCase.php +++ b/tests/Functional/SimpleWorkflowTestCase.php @@ -94,7 +94,7 @@ public function testLocalActivity(): void ); foreach ($history as $item) { if ($item->getEventType() === EventType::EVENT_TYPE_MARKER_RECORDED && - $item->getMarkerRecordedEventAttributes()->getMarkerName() === 'LocalActivity' + \in_array($item->getMarkerRecordedEventAttributes()->getMarkerName(), ['LocalActivity', 'core_local_activity'], true) ) { // LocalActivity found $this->assertTrue(true); diff --git a/tests/Functional/WorkflowTestCase.php b/tests/Functional/WorkflowTestCase.php index 43fb5d38a..545d684b1 100644 --- a/tests/Functional/WorkflowTestCase.php +++ b/tests/Functional/WorkflowTestCase.php @@ -130,10 +130,6 @@ public function testActivity(): void public function testExecuteProtoWorkflow(): void { - if (\extension_loaded('protobuf')) { - $this->markTestSkipped('The test conflicts with "protobuf" extension.'); - } - $worker = WorkerMock::createMock(); $worker->run($this, Splitter::create('Test_ExecuteProtoWorkflow.log')->getQueue()); diff --git a/tests/Functional/bootstrap.php b/tests/Functional/bootstrap.php index 14b080523..29249e6f5 100644 --- a/tests/Functional/bootstrap.php +++ b/tests/Functional/bootstrap.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Temporal\Tests\CoreWorker; use Temporal\Testing\Environment; use Temporal\Testing\SystemInfo; use Temporal\Tests\SearchAttributeTestInvoker; @@ -19,22 +20,39 @@ $environment = Environment::create(systemInfo: $systemInfo); $environment->startTemporalTestServer(); (new SearchAttributeTestInvoker())(); -$environment->startRoadRunner( - rrCommand: [ - $rootDir . DIRECTORY_SEPARATOR . $systemInfo->rrExecutable, - 'serve', - '-c', $configFile, - '-w', $configDir, - '-o', - 'server.command=' . \implode(',', [ - PHP_BINARY, - ...$environment->command->getPhpBinaryArguments(), - 'worker.php', - ...$environment->command->getCommandLineArguments(), - ]), - ], - configFile: $configFile, -); +if (CoreWorker::enabled()) { + $workerCommand = \implode(' ', [PHP_BINARY, ...$environment->command->getPhpBinaryArguments(), 'worker.php']); + $coreWorker = CoreWorker::start( + [ + $rootDir . DIRECTORY_SEPARATOR . $systemInfo->rrExecutable, + 'serve', + '-c', '.rr.core.yaml', + '-o', "service.workflow.command=$workerCommand", + '-o', "service.activity.command=$workerCommand", + ], + $configDir, + $rootDir . '/runtime/tests/functional-core-worker.log', + [], + ); + \register_shutdown_function(static fn() => $coreWorker->stop(5)); +} else { + $environment->startRoadRunner( + rrCommand: [ + $rootDir . DIRECTORY_SEPARATOR . $systemInfo->rrExecutable, + 'serve', + '-c', $configFile, + '-w', $configDir, + '-o', + 'server.command=' . \implode(',', [ + PHP_BINARY, + ...$environment->command->getPhpBinaryArguments(), + 'worker.php', + ...$environment->command->getCommandLineArguments(), + ]), + ], + configFile: $configFile, + ); +} \register_shutdown_function(static fn() => $environment->stop()); diff --git a/tests/Functional/worker.php b/tests/Functional/worker.php index e0a38615d..3be7d6b9e 100644 --- a/tests/Functional/worker.php +++ b/tests/Functional/worker.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Temporal\Testing\CoreWorkerFactory; +use Temporal\Tests\CoreWorker; use Temporal\Testing\MockChildWorkflowInterceptor; use Temporal\Testing\MockSearchAttributeInterceptor; use Temporal\Testing\MockSideEffectInterceptor; @@ -36,7 +38,7 @@ } }; -$factory = WorkerFactory::create(); +$factory = CoreWorker::enabled() ? CoreWorkerFactory::create() : WorkerFactory::create(); $interceptors = [ InterceptorCallsCounter::class, @@ -85,4 +87,4 @@ } } -$factory->run(); +exit($factory->run()); diff --git a/tests/SearchAttributeTestInvoker.php b/tests/SearchAttributeTestInvoker.php index 3b54e65ff..16d2b29e1 100644 --- a/tests/SearchAttributeTestInvoker.php +++ b/tests/SearchAttributeTestInvoker.php @@ -4,30 +4,27 @@ namespace Temporal\Tests; -use Temporal\Api\Operatorservice\V1\OperatorServiceClient; -use Grpc\ChannelCredentials; use Temporal\Api\Operatorservice\V1\AddSearchAttributesRequest; +use Temporal\Client\GRPC\OperatorClient; +use Temporal\Exception\Client\ServiceClientException; use Temporal\Testing\TemporalServer; final class SearchAttributeTestInvoker { public function __invoke(): void { - $operation = new OperatorServiceClient( - TemporalServer::address(), - ['credentials' => ChannelCredentials::createInsecure()] - ); - $result = $operation->AddSearchAttributes( - new AddSearchAttributesRequest( - [ - 'search_attributes' => [ - 'attr1' => 2, // Keyword - 'attr2' => 5, // Bool + try { + OperatorClient::create(TemporalServer::address())->AddSearchAttributes( + new AddSearchAttributesRequest( + [ + 'search_attributes' => [ + 'attr1' => 2, // Keyword + 'attr2' => 5, // Bool + ] ] - ] - ) - ); - - $result->getMetadata(); + ) + ); + } catch (ServiceClientException) { + } } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 2d4e44c8e..0bfda62cd 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -13,6 +13,10 @@ use Carbon\CarbonInterval; use PHPUnit\Framework\TestCase as BaseTestCase; +use Temporal\Client\WorkflowClientInterface; +use Temporal\Testing\Replay\CoreWorkflowReplayer; +use Temporal\Testing\Replay\WorkflowReplayer; +use Temporal\Worker\Core\CoreWorkerFactory; abstract class TestCase extends BaseTestCase { @@ -30,4 +34,16 @@ protected function assertEqualIntervals(\DateInterval $expected, \DateInterval $ $this->assertEquals($expected, $actual); } + + protected function createReplayer(WorkflowClientInterface $client, string ...$workflows): WorkflowReplayer|CoreWorkflowReplayer + { + if (!CoreWorker::enabled()) { + return new WorkflowReplayer(); + } + + $factory = CoreWorkerFactory::create(); + $factory->newWorker()->registerWorkflowTypes(...$workflows); + + return new CoreWorkflowReplayer($factory, $client); + } } diff --git a/tests/Unit/Activity/ActivityContextHeartbeatTestCase.php b/tests/Unit/Activity/ActivityContextHeartbeatTestCase.php new file mode 100644 index 000000000..b09d2e9de --- /dev/null +++ b/tests/Unit/Activity/ActivityContextHeartbeatTestCase.php @@ -0,0 +1,36 @@ +taskToken = 'token'; + $rpc = $this->createMock(RPCConnectionInterface::class); + $rpc->expects($this->once()) + ->method('call') + ->with('temporal.RecordActivityHeartbeat', $this->callback( + static fn(array $arguments): bool => $arguments['taskToken'] === \base64_encode('token'), + )) + ->willReturn([]); + $context = new ActivityContext($rpc, DataConverter::createDefault(), EncodedValues::empty(), Header::empty(), null, $info); + + $context->heartbeat('progress'); + + $this->assertSame($info, $context->getInfo()); + } +} diff --git a/tests/Unit/Client/GRPC/BaseClientTestCase.php b/tests/Unit/Client/GRPC/BaseClientTestCase.php index 845ca3b22..59c98da85 100644 --- a/tests/Unit/Client/GRPC/BaseClientTestCase.php +++ b/tests/Unit/Client/GRPC/BaseClientTestCase.php @@ -15,6 +15,7 @@ use Temporal\Client\GRPC\ContextInterface; use Temporal\Client\GRPC\ServiceClient; use Temporal\Client\GRPC\StatusCode; +use Temporal\Exception\Client\CanceledException; use Temporal\Exception\Client\ServiceClientException; use Temporal\Exception\Client\TimeoutException; use Temporal\Internal\Interceptor\Pipeline; @@ -177,6 +178,40 @@ public function close(): void {} $client->testCall(); } + public function testCancelledAfterDeadlineIsTimeout(): void + { + $client = $this->failingClient(StatusCode::CANCELLED); + $client = $client->withContext($client->getContext()->withDeadline(new \DateTimeImmutable('-1 second'))); + + self::expectException(TimeoutException::class); + + $client->testCall(); + } + + public function testCancelledBeforeDeadlineIsCanceled(): void + { + $client = $this->failingClient(StatusCode::CANCELLED); + $client = $client->withContext($client->getContext()->withDeadline(new \DateTimeImmutable('+1 minute'))); + + self::expectException(CanceledException::class); + + $client->testCall(); + } + + public function testRetryableErrorAfterTimeoutIsTimeout(): void + { + $client = $this->failingClient(StatusCode::UNAVAILABLE, 20_000); + $client = $client->withContext( + $client->getContext() + ->withTimeout(0.01) + ->withRetryOptions(RpcRetryOptions::new()->withMaximumAttempts(1)), + ); + + self::expectException(TimeoutException::class); + + $client->testCall(); + } + public function testServiceClientCallCustomException(): void { $client = $this->createClientMock(static fn() => new class extends WorkflowServiceClient { @@ -244,6 +279,24 @@ public function close(): void {} } } + private function failingClient(int $code, int $delayMicroseconds = 0): BaseClient + { + return $this->createClientMock(static fn() => new class($code, $delayMicroseconds) extends WorkflowServiceClient { + public function __construct( + private readonly int $code, + private readonly int $delayMicroseconds, + ) {} + + public function testCall(): void + { + \usleep($this->delayMicroseconds); + throw new class((object) ['code' => $this->code, 'metadata' => []]) extends ServiceClientException {}; + } + + public function close(): void {} + })->withInterceptorPipeline(null); + } + private function createClientMock(?callable $serviceClientFactory = null): BaseClient { return (new class($serviceClientFactory ?? static fn() => new class extends WorkflowServiceClient { diff --git a/tests/Unit/Internal/Bridge/BridgeConnectionTestCase.php b/tests/Unit/Internal/Bridge/BridgeConnectionTestCase.php new file mode 100644 index 000000000..fddef7d05 --- /dev/null +++ b/tests/Unit/Internal/Bridge/BridgeConnectionTestCase.php @@ -0,0 +1,36 @@ + 'http://host:7233', 'tls' => null, 'connect_timeout_ms' => BridgeConnection::CONNECT_TIMEOUT_MS], + BridgeConnection::client('host:7233', null), + ); + self::assertSame('https://host:7233', BridgeConnection::client('host:7233', [])['target_url']); + } + + public function testTlsReadsCertificateFiles(): void + { + $file = (string) \tempnam(\sys_get_temp_dir(), 'pem'); + \file_put_contents($file, self::PEM); + try { + self::assertSame( + ['server_root_ca_cert' => self::PEM, 'domain' => 'example.com', 'client_cert' => self::PEM, 'client_private_key' => null], + BridgeConnection::tls($file, 'example.com', self::PEM, ''), + ); + } finally { + \unlink($file); + } + } +} diff --git a/tests/Unit/Internal/Bridge/CoreEnvironmentTestCase.php b/tests/Unit/Internal/Bridge/CoreEnvironmentTestCase.php new file mode 100644 index 000000000..79f6a9aad --- /dev/null +++ b/tests/Unit/Internal/Bridge/CoreEnvironmentTestCase.php @@ -0,0 +1,51 @@ + [null, static fn(): ?string => CoreEnvironment::string(self::NAME), null]; + yield 'string empty' => ['', static fn(): ?string => CoreEnvironment::string(self::NAME), null]; + yield 'string set' => ['value', static fn(): ?string => CoreEnvironment::string(self::NAME), 'value']; + yield 'integer default' => [null, static fn(): int => CoreEnvironment::integer(self::NAME, 7, 1), 7]; + yield 'integer set' => ['3', static fn(): int => CoreEnvironment::integer(self::NAME, 7, 1), 3]; + yield 'integer below minimum' => ['0', static fn(): int => CoreEnvironment::integer(self::NAME, 7, 1), new \InvalidArgumentException(self::NAME . ' must be an integer not less than 1, "0" given')]; + yield 'integer not a number' => ['many', static fn(): int => CoreEnvironment::integer(self::NAME, 7, 1), new \InvalidArgumentException(self::NAME . ' must be an integer not less than 1, "many" given')]; + yield 'flag default' => [null, static fn(): bool => CoreEnvironment::flag(self::NAME, true), true]; + yield 'flag off' => ['off', static fn(): bool => CoreEnvironment::flag(self::NAME, true), false]; + yield 'flag on' => ['1', static fn(): bool => CoreEnvironment::flag(self::NAME, false), true]; + yield 'fraction unset' => [null, static fn(): ?float => CoreEnvironment::fraction(self::NAME), null]; + yield 'fraction set' => ['0.75', static fn(): ?float => CoreEnvironment::fraction(self::NAME), 0.75]; + yield 'fraction above one' => ['1.5', static fn(): ?float => CoreEnvironment::fraction(self::NAME), new \InvalidArgumentException(self::NAME . ' must be a number from 0 to 1, "1.5" given')]; + yield 'fraction not a number' => ['most', static fn(): ?float => CoreEnvironment::fraction(self::NAME), new \InvalidArgumentException(self::NAME . ' must be a number from 0 to 1, "most" given')]; + yield 'map unset' => [null, static fn(): ?array => CoreEnvironment::map(self::NAME), null]; + yield 'map set' => [' a = b ,token=x=y', static fn(): ?array => CoreEnvironment::map(self::NAME), ['a' => 'b', 'token' => 'x=y']]; + yield 'map pair without value' => ['a=b,c', static fn(): ?array => CoreEnvironment::map(self::NAME), new \InvalidArgumentException(self::NAME . ' must be a comma-separated list of key=value pairs, "a=b,c" given')]; + yield 'map pair without key' => ['=b', static fn(): ?array => CoreEnvironment::map(self::NAME), new \InvalidArgumentException(self::NAME . ' must be a comma-separated list of key=value pairs, "=b" given')]; + yield 'flag invalid' => ['maybe', static fn(): bool => CoreEnvironment::flag(self::NAME, true), new \InvalidArgumentException(self::NAME . ' must be a boolean, "maybe" given')]; + } + + #[DataProvider('provideValues')] + public function testRead(?string $value, \Closure $read, mixed $expected): void + { + $_SERVER[self::NAME] = $value; + try { + if ($expected instanceof \Throwable) { + $this->expectExceptionObject($expected); + } + self::assertSame($expected, $read()); + } finally { + unset($_SERVER[self::NAME]); + } + } +} diff --git a/tests/Unit/Internal/Workflow/WorkflowContextActivityStubTestCase.php b/tests/Unit/Internal/Workflow/WorkflowContextActivityStubTestCase.php new file mode 100644 index 000000000..5d9c758ab --- /dev/null +++ b/tests/Unit/Internal/Workflow/WorkflowContextActivityStubTestCase.php @@ -0,0 +1,46 @@ +client, $instance, new Input(), EncodedValues::empty()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Local activity can be used only with LocalActivityOptions'); + + $context->newActivityStub(DummyLocalActivity::class, ActivityOptions::new()); + } +} diff --git a/tests/Unit/Router/InvokeActivityTestCase.php b/tests/Unit/Router/InvokeActivityTestCase.php index cb166adff..ddca494c9 100644 --- a/tests/Unit/Router/InvokeActivityTestCase.php +++ b/tests/Unit/Router/InvokeActivityTestCase.php @@ -4,12 +4,14 @@ namespace Temporal\Tests\Unit\Router; +use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\NullLogger; use React\Promise\Deferred; use Spiral\Attributes\AnnotationReader; use Spiral\Attributes\AttributeReader; use Spiral\Attributes\Composite\SelectiveReader; use Spiral\Attributes\ReaderInterface; +use Temporal\Activity\ActivityInfo; use Temporal\DataConverter\DataConverterInterface; use Temporal\DataConverter\EncodedValues; use Temporal\Exception\ExceptionInterceptorInterface; @@ -27,6 +29,8 @@ use Temporal\Tests\Unit\AbstractUnit; use Temporal\Worker\Environment\EnvironmentInterface; use Temporal\Worker\LoopInterface; +use Temporal\Worker\Transport\Command\Server\ServerRequest; +use Temporal\Worker\Transport\Command\Server\TickInfo; use Temporal\Worker\Transport\RPCConnectionInterface; final class InvokeActivityTestCase extends AbstractUnit @@ -34,6 +38,7 @@ final class InvokeActivityTestCase extends AbstractUnit private ServiceContainer $services; private InvokeActivity $router; private ActivityContext $activityContext; + private MarshallerInterface&MockObject $marshaller; public function testFinalizerIsCalledOnSuccessActivityInvocation(): void { @@ -44,6 +49,7 @@ static function () use (&$finalizerWasCalled): void { }, ); + $this->expectUnmarshal(); $this->activityContext->getInfo()->type->name = 'DummyActivityDoNothing'; $request = new Request('DummyActivityDoNothing', EncodedValues::fromValues([])); $this->router->handle($request, [], new Deferred()); @@ -61,28 +67,43 @@ function (\Throwable $error) use (&$finalizerWasCalled): void { }, ); + $this->expectUnmarshal(); $this->activityContext->getInfo()->type->name = 'DummyActivityDoFail'; $request = new Request('DummyActivityDoFail', EncodedValues::fromValues([])); $this->router->handle($request, [], new Deferred()); $this->assertTrue($finalizerWasCalled); } - protected function setUp(): void + public function testPrebuiltInfoIsUsedWithoutTheMarshaller(): void { - $rpc = $this->createMock(RPCConnectionInterface::class); + $this->marshaller->expects($this->never())->method('unmarshal'); + $info = new ActivityInfo(); + $info->type->name = 'DummyActivityDoNothing'; + $finalizerWasCalled = false; + $this->services->activities->addFinalizer( + static function () use (&$finalizerWasCalled): void { + $finalizerWasCalled = true; + }, + ); - $dataConverter = $this->createMock(DataConverterInterface::class); - $marshaller = $this->createMock(MarshallerInterface::class); - $this->activityContext = new ActivityContext( - $rpc, - $dataConverter, - EncodedValues::empty(), - Header::empty(), + $request = new ServerRequest( + name: 'InvokeActivity', + info: new TickInfo(new \DateTimeImmutable()), + options: ['name' => 'DummyActivityDoNothing', 'info' => $info], + payloads: EncodedValues::fromValues([]), + id: 'activity-id', ); - $marshaller->expects($this->once()) - ->method('unmarshal') - ->willReturn($this->activityContext); + $this->router->handle($request, [], new Deferred()); + $this->assertTrue($finalizerWasCalled); + } + + protected function setUp(): void + { + $rpc = $this->createMock(RPCConnectionInterface::class); + $dataConverter = $this->createMock(DataConverterInterface::class); + $this->marshaller = $marshaller = $this->createMock(MarshallerInterface::class); + $this->activityContext = new ActivityContext($rpc, $dataConverter, EncodedValues::empty(), Header::empty()); $this->services = new ServiceContainer( $this->createMock(LoopInterface::class), $this->createMock(EnvironmentInterface::class), @@ -104,4 +125,11 @@ protected function setUp(): void parent::setUp(); } + + private function expectUnmarshal(): void + { + $this->marshaller->expects($this->once()) + ->method('unmarshal') + ->willReturn($this->activityContext); + } } diff --git a/tests/Unit/Router/StartWorkflowTestCase.php b/tests/Unit/Router/StartWorkflowTestCase.php index a42f54c8c..ede3ddd6e 100644 --- a/tests/Unit/Router/StartWorkflowTestCase.php +++ b/tests/Unit/Router/StartWorkflowTestCase.php @@ -34,6 +34,8 @@ use Temporal\Tests\Unit\AbstractUnit; use Temporal\Worker\Environment\EnvironmentInterface; use Temporal\Worker\LoopInterface; +use Temporal\Worker\Transport\Command\Server\ServerRequest; +use Temporal\Worker\Transport\Command\Server\TickInfo; use Temporal\Workflow\WorkflowExecution; use Temporal\Workflow\WorkflowInfo; @@ -61,6 +63,26 @@ public function testWorkflowIsStartedAndRunning(): void $this->assertNotNull($this->services->running->find($workflowInfo->execution->getRunID())); } + public function testPrebuiltInfoIsUsedWithoutTheMarshaller(): void + { + $runId = Uuid::v4(); + $workflowInfo = new WorkflowInfo(); + $workflowInfo->type->name = 'DummyWorkflow'; + $workflowInfo->execution = new WorkflowExecution('123', $runId); + $request = new ServerRequest( + name: 'StartWorkflow', + info: new TickInfo(new \DateTimeImmutable()), + options: ['info' => $workflowInfo], + payloads: EncodedValues::fromValues([]), + id: $runId, + ); + + $this->marshaller->expects($this->never())->method('unmarshal'); + + $this->router->handle($request, [], new Deferred()); + $this->assertNotNull($this->services->running->find($runId)); + } + public function testRequestRunId(): void { $request = new Request($runId = Uuid::v4(), DummyWorkflow::class, EncodedValues::fromValues([])); diff --git a/tests/Unit/Testing/CoreWorkerFactoryTestCase.php b/tests/Unit/Testing/CoreWorkerFactoryTestCase.php new file mode 100644 index 000000000..929933409 --- /dev/null +++ b/tests/Unit/Testing/CoreWorkerFactoryTestCase.php @@ -0,0 +1,23 @@ +newWorker('mocked-queue'); + + $this->assertInstanceOf(WorkerMock::class, $worker); + $this->assertSame('mocked-queue', $worker->getID()); + } +} diff --git a/tests/Unit/Testing/Replay/HistoryJsonCodecTestCase.php b/tests/Unit/Testing/Replay/HistoryJsonCodecTestCase.php new file mode 100644 index 000000000..5e3a23b2d --- /dev/null +++ b/tests/Unit/Testing/Replay/HistoryJsonCodecTestCase.php @@ -0,0 +1,55 @@ + ['EVENT_TYPE_WORKFLOW_EXECUTION_STARTED']; + yield 'short name' => ['WorkflowExecutionStarted']; + yield 'short name with prefix' => ['EventTypeWorkflowExecutionStarted']; + } + + #[DataProvider('provideEventTypeNames')] + public function testEventTypeNamesDecode(string $name): void + { + $history = HistoryJsonCodec::decode(\json_encode(['events' => [['eventId' => '1', 'eventType' => $name]]])); + + $this->assertSame(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, $history->getEvents()[0]->getEventType()); + } + + public function testSnakeCaseKeysDecode(): void + { + $history = HistoryJsonCodec::decode(\json_encode(['events' => [['event_id' => '1', 'event_type' => 'WorkflowExecutionStarted']]])); + + $this->assertSame(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, $history->getEvents()[0]->getEventType()); + } + + public function testLastEventIdCutsTheHistory(): void + { + $json = \json_encode(['events' => [ + ['eventId' => '1', 'eventType' => 'WorkflowExecutionStarted'], + ['eventId' => '2', 'eventType' => 'WorkflowTaskScheduled'], + ['eventId' => '3', 'eventType' => 'WorkflowTaskStarted'], + ]]); + $this->assertCount(2, HistoryJsonCodec::decode($json, 2)->getEvents()); + $this->assertCount(3, HistoryJsonCodec::decode($json)->getEvents()); + } + + public function testEncodeDecodeRoundTrip(): void + { + $history = HistoryJsonCodec::decode(\json_encode(['events' => [['eventId' => '1', 'eventType' => 'WorkflowExecutionStarted']]])); + + $this->assertSame($history->serializeToString(), HistoryJsonCodec::decode($history->serializeToJsonString())->serializeToString()); + } +} diff --git a/tests/Unit/Testing/WorkflowInteractionsTestCase.php b/tests/Unit/Testing/WorkflowInteractionsTestCase.php index 59d4e02f8..03c2896e1 100644 --- a/tests/Unit/Testing/WorkflowInteractionsTestCase.php +++ b/tests/Unit/Testing/WorkflowInteractionsTestCase.php @@ -7,11 +7,13 @@ use Google\Protobuf\Duration; use PHPUnit\Framework\AssertionFailedError; use Temporal\Api\Common\V1\ActivityType; +use Temporal\Api\Common\V1\Payload; use Temporal\Api\Common\V1\Payloads; use Temporal\Api\Common\V1\WorkflowType; use Temporal\Api\Enums\V1\EventType; use Temporal\Api\History\V1\ActivityTaskScheduledEventAttributes; use Temporal\Api\History\V1\HistoryEvent; +use Temporal\Api\History\V1\MarkerRecordedEventAttributes; use Temporal\Api\History\V1\SignalExternalWorkflowExecutionInitiatedEventAttributes; use Temporal\Api\History\V1\StartChildWorkflowExecutionInitiatedEventAttributes; use Temporal\Api\History\V1\TimerStartedEventAttributes; @@ -20,6 +22,7 @@ use Temporal\DataConverter\EncodedValues; use Temporal\Testing\Interactions\WorkflowInteractions; use Temporal\Tests\TestCase; +use Temporal\Worker\Core\ActivityTasks; final class WorkflowInteractionsTestCase extends TestCase { @@ -44,6 +47,25 @@ public function testActivityCallCount(): void $interactions->activity('Unknown')->assertNeverCalled(); } + public function testCoreLocalActivityMarkerIsALocalActivityCall(): void + { + $interactions = WorkflowInteractions::fromEvents([ + $this->markerEvent('core_local_activity', ['activity_type' => 'Lookup']), + $this->markerEvent('LocalActivity', ['ActivityType' => 'Lookup']), + ], $this->converter); + + $interactions->localActivity('Lookup')->assertCalledTimes(2); + } + + public function testCoreSideEffectMarkerIsNotALocalActivityCall(): void + { + $interactions = WorkflowInteractions::fromEvents([ + $this->markerEvent('core_local_activity', ['activity_type' => ActivityTasks::SIDE_EFFECT]), + ], $this->converter); + + $interactions->localActivity(ActivityTasks::SIDE_EFFECT)->assertNeverCalled(); + } + public function testActivityCalledOnceFailsWhenCalledTwice(): void { $interactions = WorkflowInteractions::fromEvents([ @@ -147,6 +169,20 @@ private function childEvent(string $type, array $args): HistoryEvent ->setStartChildWorkflowExecutionInitiatedEventAttributes($attributes); } + /** + * @param array $data + */ + private function markerEvent(string $name, array $data): HistoryEvent + { + $attributes = (new MarkerRecordedEventAttributes()) + ->setMarkerName($name) + ->setDetails(['data' => (new Payloads())->setPayloads([(new Payload())->setData(\json_encode($data))])]); + + return (new HistoryEvent()) + ->setEventType(EventType::EVENT_TYPE_MARKER_RECORDED) + ->setMarkerRecordedEventAttributes($attributes); + } + private function timerEvent(int $seconds): HistoryEvent { $attributes = (new TimerStartedEventAttributes()) diff --git a/tests/Unit/Worker/Core/BridgeConstantsTestCase.php b/tests/Unit/Worker/Core/BridgeConstantsTestCase.php new file mode 100644 index 000000000..3ff9d8581 --- /dev/null +++ b/tests/Unit/Worker/Core/BridgeConstantsTestCase.php @@ -0,0 +1,29 @@ +getConstants(), + static fn(string $name): bool => \preg_match('/^(KIND|STATUS|CALL)_/', $name) === 1, + \ARRAY_FILTER_USE_KEY, + ); + + \ksort($defines); + \ksort($constants); + self::assertNotEmpty($defines); + self::assertSame(\array_intersect_key($defines, $constants), $constants); + } +} diff --git a/tests/Unit/Worker/Core/CommandTranslatorTestCase.php b/tests/Unit/Worker/Core/CommandTranslatorTestCase.php new file mode 100644 index 000000000..e5e82b270 --- /dev/null +++ b/tests/Unit/Worker/Core/CommandTranslatorTestCase.php @@ -0,0 +1,351 @@ + [ + static fn(): SuccessClientResponse => new SuccessClientResponse(1), + static function (array $messages, array $commands): void { + self::assertSame([[], []], [$messages, $commands]); + }, + ]; + yield 'failed response' => [ + static fn(): FailedClientResponse => new FailedClientResponse(1, new \DomainException('response failed')), + static function (\DomainException $error): void { + self::assertSame('response failed', $error->getMessage()); + }, + ]; + yield 'undefined response' => [ + static fn(): UndefinedResponse => new UndefinedResponse('no such command'), + static function (\LogicException $error): void { + self::assertSame('no such command', $error->getMessage()); + }, + ]; + yield 'update validated' => [ + static function (RunState $run): UpdateResponse { + $run->startUpdate('upd', 'protocol'); + return new UpdateResponse(UpdateResponse::COMMAND_VALIDATED, null, null, 'upd'); + }, + static function (array $messages, array $commands, RunState $run): void { + $response = $commands[0]->getUpdateResponse(); + self::assertSame(['protocol', 'accepted', 'protocol'], [$response->getProtocolInstanceId(), $response->getResponse(), $run->updateProtocolInstanceId('upd')]); + }, + ]; + yield 'update completed' => [ + static function (RunState $run): UpdateResponse { + $run->startUpdate('upd', 'protocol'); + return new UpdateResponse(UpdateResponse::COMMAND_COMPLETED, EncodedValues::fromValues(['done']), null, 'upd'); + }, + static function (array $messages, array $commands, RunState $run): void { + $response = $commands[0]->getUpdateResponse(); + self::assertSame('completed', $response->getResponse()); + self::assertSame('"done"', $response->getCompleted()->getData()); + self::assertSame('upd', $run->updateProtocolInstanceId('upd')); + }, + ]; + yield 'update rejected' => [ + static fn(): UpdateResponse => new UpdateResponse(UpdateResponse::COMMAND_VALIDATED, null, new \RuntimeException('invalid'), 'upd'), + static function (array $messages, array $commands): void { + self::assertSame('invalid', $commands[0]->getUpdateResponse()->getRejected()->getMessage()); + }, + ]; + yield 'activity with every option' => [ + static fn(): Request => new Request('ExecuteActivity', ['name' => 'Act', 'options' => [ + 'Summary' => 'sum', + 'ActivityID' => 'act-id', + 'TaskQueueName' => 'other', + 'StartToCloseTimeout' => 1_000_000_000, + 'RetryPolicy' => [ + 'initial_interval' => ['seconds' => 1], + 'backoff_coefficient' => 2.0, + 'maximum_interval' => ['seconds' => 10, 'nanos' => 5], + 'maximum_attempts' => 3, + 'non_retryable_error_types' => ['E'], + ], + 'Priority' => ['priority_key' => 2, 'fairness_key' => 'f', 'fairness_weight' => 1.5], + 'WaitForCancellation' => true, + ]], EncodedValues::fromValues(['arg'])), + static function (array $messages, array $commands, RunState $run): void { + $activity = $commands[0]->getScheduleActivity(); + self::assertSame([], $messages); + self::assertSame(['act-id', 'Act', 'other', 1], [$activity->getActivityId(), $activity->getActivityType(), $activity->getTaskQueue(), (int) $activity->getStartToCloseTimeout()->getSeconds()]); + self::assertSame(ActivityCancellationType::WAIT_CANCELLATION_COMPLETED, $activity->getCancellationType()); + self::assertSame([3, 5, ['E']], [$activity->getRetryPolicy()->getMaximumAttempts(), $activity->getRetryPolicy()->getMaximumInterval()->getNanos(), \iterator_to_array($activity->getRetryPolicy()->getNonRetryableErrorTypes())]); + self::assertSame(['f', 1.5], [$activity->getPriority()->getFairnessKey(), $activity->getPriority()->getFairnessWeight()]); + self::assertSame('"sum"', $commands[0]->getUserMetadata()->getSummary()->getData()); + self::assertSame('"arg"', $activity->getArguments()[0]->getData()); + self::assertFalse($run->takeTryCancel(1)); + }, + ]; + yield 'activity with defaults tries to cancel' => [ + static fn(): Request => new Request('ExecuteActivity', ['name' => 'Act', 'options' => []]), + static function (array $messages, array $commands, RunState $run): void { + $activity = $commands[0]->getScheduleActivity(); + self::assertSame(['1', 'queue', ActivityCancellationType::TRY_CANCEL], [$activity->getActivityId(), $activity->getTaskQueue(), $activity->getCancellationType()]); + self::assertNull($activity->getRetryPolicy()); + self::assertNull($activity->getPriority()); + self::assertNull($commands[0]->getUserMetadata()); + self::assertTrue($run->takeTryCancel(1)); + }, + ]; + yield 'local activity' => [ + static fn(): Request => new Request('ExecuteLocalActivity', ['name' => 'Local', 'options' => ['Summary' => 'local', 'StartToCloseTimeout' => 2_000_000_000]]), + static function (array $messages, array $commands): void { + $activity = $commands[0]->getScheduleLocalActivity(); + self::assertSame(['1', 'Local', 1, 2, 1_700_000_000], [$activity->getActivityId(), $activity->getActivityType(), $activity->getAttempt(), (int) $activity->getStartToCloseTimeout()->getSeconds(), (int) $activity->getOriginalScheduleTime()->getSeconds()]); + self::assertSame('"local"', $commands[0]->getUserMetadata()->getSummary()->getData()); + }, + ]; + yield 'timer with summary' => [ + static fn(): Request => new Request('NewTimer', ['ms' => 1500, 'summary' => 'wait']), + static function (array $messages, array $commands): void { + $timer = $commands[0]->getStartTimer(); + self::assertSame([1, 1, 500_000_000], [$timer->getSeq(), (int) $timer->getStartToFireTimeout()->getSeconds(), $timer->getStartToFireTimeout()->getNanos()]); + self::assertSame('"wait"', $commands[0]->getUserMetadata()->getSummary()->getData()); + }, + ]; + yield 'complete with result' => [ + static fn(): Request => new Request('CompleteWorkflow', [], EncodedValues::fromValues(['result'])), + static function (array $messages, array $commands): void { + self::assertSame('"result"', $commands[0]->getCompleteWorkflowExecution()->getResult()->getData()); + }, + ]; + yield 'complete with cancellation' => [ + static fn(): Request => self::failed(new Request('CompleteWorkflow'), new CanceledFailure('canceled')), + static function (array $messages, array $commands): void { + self::assertTrue($commands[0]->hasCancelWorkflowExecution()); + }, + ]; + yield 'complete with failure' => [ + static fn(): Request => self::failed(new Request('CompleteWorkflow'), new \RuntimeException('workflow failed')), + static function (array $messages, array $commands): void { + self::assertSame('workflow failed', $commands[0]->getFailWorkflowExecution()->getFailure()->getMessage()); + }, + ]; + yield 'continue as new with options' => [ + static fn(): Request => new Request('ContinueAsNew', ['name' => 'Next', 'options' => ['TaskQueueName' => 'next-queue', 'WorkflowRunTimeout' => 3_000_000_000]]), + static function (array $messages, array $commands): void { + $next = $commands[0]->getContinueAsNewWorkflowExecution(); + self::assertSame(['Next', 'next-queue', 3], [$next->getWorkflowType(), $next->getTaskQueue(), (int) $next->getWorkflowRunTimeout()->getSeconds()]); + }, + ]; + yield 'continue as new without options' => [ + static fn(): Request => new Request('ContinueAsNew', ['name' => 'Next']), + static function (array $messages, array $commands): void { + self::assertSame('queue', $commands[0]->getContinueAsNewWorkflowExecution()->getTaskQueue()); + }, + ]; + yield 'cancel every bound command' => [ + static function (RunState $run): Request { + $run->bind(self::TIMER_ID, RunState::TIMER); + $run->bind(self::ACTIVITY_ID, RunState::ACTIVITY); + $run->bind(self::LOCAL_ACTIVITY_ID, RunState::LOCAL_ACTIVITY); + $run->bind(self::CHILD_ID, RunState::CHILD); + return new Request('Cancel', ['ids' => [self::TIMER_ID, self::ACTIVITY_ID, self::LOCAL_ACTIVITY_ID, self::CHILD_ID, self::UNKNOWN_ID]]); + }, + static function (array $messages, array $commands, RunState $run): void { + self::assertSame( + ['cancel_timer', 'request_cancel_activity', 'request_cancel_local_activity', 'cancel_child_workflow_execution'], + \array_map(static fn(WorkflowCommand $command): string => $command->getVariant(), $commands), + ); + self::assertInstanceOf(FailureResponse::class, $messages[0]); + self::assertSame(self::TIMER_ID, $messages[0]->getID()); + self::assertInstanceOf(CanceledFailure::class, $messages[0]->getFailure()); + self::assertInstanceOf(SuccessResponse::class, $messages[1]); + self::assertNull($run->command(self::TIMER_ID)); + }, + ]; + yield 'get version' => [ + static fn(): Request => new Request('GetVersion', ['changeID' => 'change', 'minSupported' => 1, 'maxSupported' => 4]), + static function (array $messages, array $commands): void { + self::assertSame(4, $messages[0]->getPayloads()->getValue(0)); + self::assertSame('change-4', $commands[0]->getSetPatchMarker()->getPatchId()); + }, + ]; + yield 'side effect' => [ + static fn(): Request => new Request('SideEffect', ['summary' => 'effect'], EncodedValues::fromValues([7])), + static function (array $messages, array $commands): void { + $activity = $commands[0]->getScheduleLocalActivity(); + self::assertSame([ActivityTasks::SIDE_EFFECT, 60, '7'], [$activity->getActivityType(), (int) $activity->getScheduleToCloseTimeout()->getSeconds(), $activity->getArguments()[0]->getData()]); + self::assertSame('"effect"', $commands[0]->getUserMetadata()->getSummary()->getData()); + }, + ]; + yield 'child with every option' => [ + static fn(): Request => new Request('ExecuteChildWorkflow', ['name' => 'Child', 'options' => [ + 'Namespace' => 'child-ns', + 'WorkflowID' => 'child-id', + 'TaskQueueName' => 'child-queue', + 'CronSchedule' => '* * * * *', + 'Memo' => ['m' => 1], + 'SearchAttributes' => ['s' => 'v'], + 'StaticSummary' => 'summary', + 'StaticDetails' => 'details', + ]]), + static function (array $messages, array $commands, RunState $run): void { + $child = $commands[0]->getStartChildWorkflowExecution(); + self::assertSame(['child-ns', 'child-id', 'child-queue', '* * * * *'], [$child->getNamespace(), $child->getWorkflowId(), $child->getTaskQueue(), $child->getCronSchedule()]); + self::assertSame('1', $child->getMemo()['m']->getData()); + self::assertSame('"v"', $child->getSearchAttributes()->getIndexedFields()['s']->getData()); + self::assertSame('"details"', $commands[0]->getUserMetadata()->getDetails()->getData()); + self::assertSame(['child-id', 'child-run'], $run->childStarted($run->requestId(1), 'child-run')); + }, + ]; + yield 'child with defaults' => [ + static fn(): Request => new Request('ExecuteChildWorkflow', ['name' => 'Child', 'options' => []]), + static function (array $messages, array $commands): void { + $child = $commands[0]->getStartChildWorkflowExecution(); + self::assertSame(['ns', 'run_1', 'queue'], [$child->getNamespace(), $child->getWorkflowId(), $child->getTaskQueue()]); + self::assertNull($child->getSearchAttributes()); + self::assertNull($commands[0]->getUserMetadata()); + }, + ]; + yield 'started child execution' => [ + static function (RunState $run): Request { + $run->startChild(self::CHILD_ID, 'child-id'); + $run->childStarted(self::CHILD_ID, 'child-run'); + return new Request('GetChildWorkflowExecution', ['id' => self::CHILD_ID]); + }, + static function (array $messages): void { + $execution = $messages[0]->getPayloads()->getValue(0, WorkflowExecution::class); + self::assertSame(['child-id', 'child-run'], [$execution->getID(), $execution->getRunID()]); + }, + ]; + yield 'pending child execution' => [ + static fn(): Request => new Request('GetChildWorkflowExecution', ['id' => self::CHILD_ID]), + static function (array $messages, array $commands, RunState $run): void { + self::assertSame([], $messages); + self::assertCount(1, $run->takeChildWaiters(self::CHILD_ID)); + }, + ]; + yield 'signal child' => [ + static fn(): Request => new Request('SignalExternalWorkflow', ['namespace' => '', 'workflowID' => 'child-id', 'runID' => null, 'signal' => 'go', 'childWorkflowOnly' => true]), + static function (array $messages, array $commands): void { + $signal = $commands[0]->getSignalExternalWorkflowExecution(); + self::assertSame(['go', 'child-id'], [$signal->getSignalName(), $signal->getChildWorkflowId()]); + }, + ]; + yield 'signal external' => [ + static fn(): Request => new Request('SignalExternalWorkflow', ['namespace' => '', 'workflowID' => 'wf-id', 'runID' => 'run', 'signal' => 'go', 'childWorkflowOnly' => false]), + static function (array $messages, array $commands): void { + $execution = $commands[0]->getSignalExternalWorkflowExecution()->getWorkflowExecution(); + self::assertSame(['ns', 'wf-id', 'run'], [$execution->getNamespace(), $execution->getWorkflowId(), $execution->getRunId()]); + }, + ]; + yield 'cancel external' => [ + static fn(): Request => new Request('CancelExternalWorkflow', ['namespace' => '', 'workflowID' => 'wf-id', 'runID' => null]), + static function (array $messages, array $commands): void { + $execution = $commands[0]->getRequestCancelExternalWorkflowExecution()->getWorkflowExecution(); + self::assertSame(['ns', 'wf-id', ''], [$execution->getNamespace(), $execution->getWorkflowId(), $execution->getRunId()]); + }, + ]; + yield 'upsert search attributes' => [ + static fn(): Request => new Request('UpsertWorkflowSearchAttributes', ['searchAttributes' => ['attr' => 'value']]), + static function (array $messages, array $commands): void { + self::assertSame('"value"', $commands[0]->getUpsertWorkflowSearchAttributes()->getSearchAttributes()->getIndexedFields()['attr']->getData()); + }, + ]; + yield 'upsert typed search attributes' => [ + static fn(): Request => new Request('UpsertWorkflowTypedSearchAttributes', ['search_attributes' => \array_combine( + \array_map(static fn(ValueType $type): string => $type->value, ValueType::cases()), + \array_map(static fn(ValueType $type): array => ['type' => $type->value, 'operation' => 'set', 'value' => 'v'], ValueType::cases()), + )]), + static function (array $messages, array $commands): void { + $fields = $commands[0]->getUpsertWorkflowSearchAttributes()->getSearchAttributes()->getIndexedFields(); + foreach (ValueType::cases() as $type) { + self::assertSame($type, ValueType::fromMetadata($fields[$type->value]->getMetadata()['type'])); + } + }, + ]; + yield 'upsert memo' => [ + static fn(): Request => new Request('UpsertMemo', ['memo' => ['key' => 'value']]), + static function (array $messages, array $commands): void { + self::assertSame('"value"', $commands[0]->getModifyWorkflowProperties()->getUpsertedMemo()->getFields()['key']->getData()); + }, + ]; + yield 'panic fails the workflow' => [ + static fn(): Request => self::failed(new Request('Panic'), new \RuntimeException('panicked')), + static function (array $messages, array $commands): void { + self::assertSame('panicked', $commands[0]->getFailWorkflowExecution()->getFailure()->getMessage()); + }, + true, + ]; + yield 'panic fails the task' => [ + static fn(): Request => self::failed(new Request('Panic'), new ApplicationFailure('panicked', 'Type', false)), + static function (ApplicationFailure $error): void { + self::assertSame('panicked', $error->getOriginalMessage()); + }, + ]; + yield 'panic without failure' => [ + static fn(): Request => new Request('Panic', ['message' => 'lost']), + static function (\RuntimeException $error): void { + self::assertSame('lost', $error->getMessage()); + }, + ]; + yield 'unsupported command' => [ + static fn(): Request => new Request('Unknown'), + static function (\LogicException $error): void { + self::assertSame('Command "Unknown" is not supported by the sdk-core transport', $error->getMessage()); + }, + ]; + } + + #[DataProvider('provideCommands')] + public function testTranslate(\Closure $command, \Closure $assert, bool $failWorkflowOnPanic = false): void + { + $payloads = new PayloadMapper(DataConverter::createDefault()); + $translator = new CommandTranslator($payloads, new ResolutionMapper($payloads, 'ns'), 'ns', 'queue', $failWorkflowOnPanic); + $run = new RunState('run'); + $commands = []; + + try { + $messages = $translator->translate($run, $command($run), new TickInfo(new \DateTimeImmutable('@1700000000')), $commands); + } catch (\Throwable $error) { + $assert($error, $commands, $run); + return; + } + $assert($messages, $commands, $run); + } + + private static function failed(Request $request, \Throwable $failure): Request + { + $request->setFailure($failure); + + return $request; + } +} diff --git a/tests/Unit/Worker/Core/CoreInfoMappingTestCase.php b/tests/Unit/Worker/Core/CoreInfoMappingTestCase.php new file mode 100644 index 000000000..3c296ff65 --- /dev/null +++ b/tests/Unit/Worker/Core/CoreInfoMappingTestCase.php @@ -0,0 +1,342 @@ + [new Start([ + 'workflow_namespace' => 'wf-ns', + 'workflow_type' => 'WfType', + 'workflow_execution' => new WorkflowExecution(['workflow_id' => 'wf-id', 'run_id' => 'run-id']), + 'activity_id' => '7', + 'activity_type' => 'Act.echo', + 'scheduled_time' => new Timestamp(['seconds' => 1_790_000_000, 'nanos' => 123_456_789]), + 'started_time' => new Timestamp(['seconds' => 1_790_000_001, 'nanos' => 5_000_000]), + 'attempt' => 3, + 'schedule_to_close_timeout' => new Duration(['seconds' => 60]), + 'start_to_close_timeout' => new Duration(['seconds' => 30, 'nanos' => 500_000_000]), + 'heartbeat_timeout' => new Duration(['seconds' => 5, 'nanos' => 250]), + 'retry_policy' => new RetryPolicy([ + 'initial_interval' => new Duration(['seconds' => 1]), + 'backoff_coefficient' => 2.5, + 'maximum_interval' => new Duration(['seconds' => 100]), + 'maximum_attempts' => 4, + 'non_retryable_error_types' => ['A', 'B'], + ]), + 'priority' => new Priority(['priority_key' => 2, 'fairness_key' => 'tenant', 'fairness_weight' => 1.3]), + ])]; + yield 'minimal' => [new Start([ + 'workflow_type' => 'WfType', + 'activity_id' => '1', + 'activity_type' => 'Act.echo', + 'scheduled_time' => new Timestamp(['seconds' => 1_790_000_000]), + 'started_time' => new Timestamp(['seconds' => 1_790_000_000]), + 'attempt' => 1, + 'schedule_to_close_timeout' => new Duration(['seconds' => 10]), + ])]; + yield 'retry started late, schedule-to-close binds' => [new Start([ + 'workflow_type' => 'WfType', + 'activity_id' => '2', + 'activity_type' => 'Act.echo', + 'scheduled_time' => new Timestamp(['seconds' => 1_790_000_000, 'nanos' => 250_000]), + 'started_time' => new Timestamp(['seconds' => 1_790_000_050, 'nanos' => 999_999_999]), + 'attempt' => 4, + 'schedule_to_close_timeout' => new Duration(['seconds' => 60]), + 'start_to_close_timeout' => new Duration(['seconds' => 30]), + ])]; + yield 'start-to-close only' => [new Start([ + 'workflow_type' => 'WfType', + 'activity_id' => '3', + 'activity_type' => 'Act.echo', + 'scheduled_time' => new Timestamp(['seconds' => 1_790_000_000]), + 'started_time' => new Timestamp(['seconds' => 1_790_000_020, 'nanos' => 1_000]), + 'attempt' => 1, + 'start_to_close_timeout' => new Duration(['seconds' => 5]), + ])]; + } + + public static function provideInits(): iterable + { + yield 'minimal' => [new InitializeWorkflow([ + 'workflow_type' => 'WfType', + 'workflow_id' => 'wf-id', + 'attempt' => 1, + 'first_execution_run_id' => 'run-id', + ])]; + yield 'full' => [new InitializeWorkflow([ + 'workflow_type' => 'WfType', + 'workflow_id' => 'wf-id', + 'attempt' => 2, + 'first_execution_run_id' => 'first-run', + 'original_execution_run_id' => 'original-run', + 'continued_from_execution_run_id' => 'prev-run', + 'cron_schedule' => '* * * * *', + 'workflow_execution_timeout' => new Duration(['seconds' => 3600]), + 'workflow_run_timeout' => new Duration(['seconds' => 600, 'nanos' => 1_000]), + 'workflow_task_timeout' => new Duration(['seconds' => 10]), + 'parent_workflow_info' => new NamespacedWorkflowExecution(['namespace' => 'parent-ns', 'workflow_id' => 'parent', 'run_id' => 'parent-run']), + 'root_workflow' => new WorkflowExecution(['workflow_id' => 'root', 'run_id' => 'root-run']), + 'retry_policy' => new RetryPolicy(['backoff_coefficient' => 2.0, 'maximum_attempts' => 5]), + 'priority' => new Priority(['priority_key' => 1, 'fairness_weight' => 0.7]), + ])]; + } + + #[DataProvider('provideStarts')] + public function testActivityInfoEqualsMarshalledRoadRunnerShape(Start $start): void + { + $token = "token\x00bytes"; + $actual = $this->info->activityInfo($token, $start, 'queue'); + + $expected = $this->marshaller->unmarshal($this->activityInfoArray($token, $start, 'queue'), new ActivityInfo()); + + self::assertEquals($expected, $actual); + self::assertSame($expected->deadline->format('Y-m-d\TH:i:s.uP'), $actual->deadline->format('Y-m-d\TH:i:s.uP')); + self::assertSame($expected->startedTime->format('Y-m-d\TH:i:s.uP'), $actual->startedTime->format('Y-m-d\TH:i:s.uP')); + } + + public function testActivityDeadlineIsScheduleToCloseWhenItEndsFirst(): void + { + [$start] = \iterator_to_array(self::provideStarts())['retry started late, schedule-to-close binds']; + + $info = $this->info->activityInfo('token', $start, 'queue'); + + self::assertSame('2026-09-21T14:14:20.000250+00:00', $info->deadline->format('Y-m-d\TH:i:s.uP')); + } + + public function testRoadRunnerActivityShapeCoversEveryActivityInfoField(): void + { + [$start] = \iterator_to_array(self::provideStarts())['full']; + + self::assertEqualsCanonicalizing( + self::marshalledNames(ActivityInfo::class), + \array_keys($this->activityInfoArray('token', $start, 'queue')), + ); + } + + public function testRoadRunnerWorkflowShapeCoversEveryWorkflowInfoField(): void + { + [$init] = \iterator_to_array(self::provideInits())['full']; + + self::assertEqualsCanonicalizing( + self::marshalledNames(WorkflowInfo::class), + [...\array_keys($this->workflowInfoArray($init, 'run-id')), ...self::WORKFLOW_INFO_TICK_FIELDS], + ); + } + + #[DataProvider('provideInits')] + public function testWorkflowInfoEqualsMarshalledRoadRunnerShape(InitializeWorkflow $init): void + { + $this->assertWorkflowInfo($init); + } + + public function testWorkflowInfoWithSearchAttributesAndMemo(): void + { + $keyword = $this->converter->toPayload('value'); + $keyword->getMetadata()['type'] = 'Keyword'; + $number = $this->converter->toPayload(42); + $number->getMetadata()['type'] = 'Int'; + + $this->assertWorkflowInfo(new InitializeWorkflow([ + 'workflow_type' => 'WfType', + 'workflow_id' => 'wf-id', + 'attempt' => 1, + 'search_attributes' => new SearchAttributes(['indexed_fields' => [ + 'Kw' => $keyword, + 'Num' => $number, + 'Untyped' => $this->converter->toPayload('raw'), + ]]), + 'memo' => new Memo(['fields' => ['m' => $this->converter->toPayload(['a' => 1])]]), + ])); + } + + protected function setUp(): void + { + $this->converter = DataConverter::createDefault(); + $this->marshaller = new Marshaller(new AttributeMapperFactory(new AttributeReader())); + $this->info = new InfoFactory(new PayloadMapper($this->converter)); + } + + /** + * @param class-string $class + * @return list + */ + private static function marshalledNames(string $class): array + { + $names = []; + foreach ((new \ReflectionClass($class))->getProperties() as $property) { + foreach ($property->getAttributes(Marshal::class) as $attribute) { + $names[] = $attribute->newInstance()->name; + } + } + + return $names; + } + + private function assertWorkflowInfo(InitializeWorkflow $init): void + { + $actual = $this->info->workflowInfo($init, 'run-id', 'ns', 'queue'); + + $expected = $this->marshaller->unmarshal(['info' => $this->workflowInfoArray($init, 'run-id')], new Input())->info; + + self::assertSame(\iterator_to_array($this->typed($expected->typedSearchAttributes)), \iterator_to_array($this->typed($actual->typedSearchAttributes))); + $expected->typedSearchAttributes = $actual->typedSearchAttributes; + self::assertEquals($expected, $actual); + } + + private function typed(TypedSearchAttributes $attributes): \Generator + { + foreach ($attributes as $key => $value) { + yield $key->getName() => [$key->getType(), $value]; + } + } + + private function workflowInfoArray(InitializeWorkflow $init, string $runId): array + { + $execution = static fn(?string $id, ?string $run): ?array => $id ? ['ID' => $id, 'RunID' => (string) $run] : null; + $typed = []; + if ($init->hasSearchAttributes()) { + foreach ($init->getSearchAttributes()->getIndexedFields() as $name => $payload) { + $type = $payload->getMetadata()['type'] ?? null; + if ($type !== null) { + $typed[$name] = ['type' => $type, 'value' => $this->converter->fromPayload($payload, null)]; + } + } + } + + return [ + 'WorkflowExecution' => ['ID' => $init->getWorkflowId(), 'RunID' => $runId], + 'WorkflowType' => ['Name' => $init->getWorkflowType()], + 'TaskQueueName' => 'queue', + 'WorkflowExecutionTimeout' => $this->nanos($init->getWorkflowExecutionTimeout()), + 'WorkflowRunTimeout' => $this->nanos($init->getWorkflowRunTimeout()), + 'WorkflowTaskTimeout' => $this->nanos($init->getWorkflowTaskTimeout()), + 'Namespace' => 'ns', + 'Attempt' => $init->getAttempt(), + 'CronSchedule' => $init->getCronSchedule() ?: null, + 'ContinuedExecutionRunID' => $init->getContinuedFromExecutionRunId(), + 'FirstRunID' => $init->getFirstExecutionRunId(), + 'OriginalRunID' => $init->getOriginalExecutionRunId() ?: $runId, + 'ParentWorkflowNamespace' => $init->getParentWorkflowInfo()?->getNamespace() ?? '', + 'ParentWorkflowExecution' => $execution($init->getParentWorkflowInfo()?->getWorkflowId(), $init->getParentWorkflowInfo()?->getRunId()), + 'RootWorkflowExecution' => $execution($init->getRootWorkflow()?->getWorkflowId(), $init->getRootWorkflow()?->getRunId()), + 'SearchAttributes' => $init->hasSearchAttributes() ? $this->jsonCollection(new SearchAttributes(), $init->getSearchAttributes()->serializeToJsonString(), 'getIndexedFields') : null, + 'Memo' => $init->hasMemo() ? $this->jsonCollection(new Memo(), $init->getMemo()->serializeToJsonString(), 'getFields') : null, + 'RetryPolicy' => $this->retryArray($init->getRetryPolicy()), + 'Priority' => $this->priorityArray($init->getPriority()), + 'TypedSearchAttributes' => $init->hasSearchAttributes() ? TypedSearchAttributes::fromJsonArray($typed) : TypedSearchAttributes::empty(), + ]; + } + + private function jsonCollection(SearchAttributes|Memo $message, string $json, string $getter): array + { + $message->mergeFromJsonString($json, true); + + return EncodedCollection::fromPayloadCollection($message->$getter(), $this->converter)->getValues(); + } + + private function activityInfoArray(string $token, Start $start, string $taskQueue): array + { + $scheduled = $this->timestampNanos($start->getScheduledTime()); + $started = $this->timestampNanos($start->getStartedTime()); + $scheduleToClose = $this->nanos($start->getScheduleToCloseTimeout()); + $startToClose = $this->nanos($start->getStartToCloseTimeout()); + $deadlines = \array_filter([ + $scheduleToClose > 0 ? $scheduled + $scheduleToClose : null, + $startToClose > 0 ? $started + $startToClose : null, + ]); + + return [ + 'TaskToken' => $token, + 'WorkflowType' => ['Name' => $start->getWorkflowType()], + 'WorkflowNamespace' => $start->getWorkflowNamespace(), + 'WorkflowExecution' => [ + 'ID' => $start->getWorkflowExecution()?->getWorkflowId() ?? '', + 'RunID' => $start->getWorkflowExecution()?->getRunId() ?? '', + ], + 'ActivityID' => $start->getActivityId(), + 'ActivityType' => ['Name' => $start->getActivityType()], + 'TaskQueue' => $taskQueue, + 'HeartbeatTimeout' => $this->nanos($start->getHeartbeatTimeout()), + 'ScheduledTime' => $this->rfc3339Nano($scheduled), + 'StartedTime' => $this->rfc3339Nano($started), + 'Deadline' => $this->rfc3339Nano($deadlines === [] ? $started : \min($deadlines)), + 'Attempt' => $start->getAttempt(), + 'RetryPolicy' => $this->retryArray($start->getRetryPolicy()), + 'Priority' => $this->priorityArray($start->getPriority()), + ]; + } + + private function rfc3339Nano(int $nanos): string + { + $fraction = \rtrim(\sprintf('%09d', $nanos % self::NANOS_PER_SECOND), '0'); + + return \gmdate('Y-m-d\TH:i:s', \intdiv($nanos, self::NANOS_PER_SECOND)) . ($fraction === '' ? '' : '.' . $fraction) . 'Z'; + } + + private function timestampNanos(Timestamp $timestamp): int + { + return $timestamp->getSeconds() * self::NANOS_PER_SECOND + $timestamp->getNanos(); + } + + private function retryArray(?RetryPolicy $retry): ?array + { + return $retry === null ? null : [ + 'InitialInterval' => $this->nanos($retry->getInitialInterval()), + 'BackoffCoefficient' => $retry->getBackoffCoefficient(), + 'MaximumInterval' => $this->nanos($retry->getMaximumInterval()), + 'MaximumAttempts' => $retry->getMaximumAttempts(), + 'NonRetryableErrorTypes' => \iterator_to_array($retry->getNonRetryableErrorTypes()), + ]; + } + + private function priorityArray(?Priority $priority): array + { + return [ + 'PriorityKey' => $priority?->getPriorityKey() ?? 0, + 'FairnessKey' => $priority?->getFairnessKey() ?? '', + 'FairnessWeight' => (float) \sprintf('%.7g', $priority?->getFairnessWeight() ?? 0.0), + ]; + } + + private function nanos(?Duration $duration): int + { + return $duration === null ? 0 : $duration->getSeconds() * self::NANOS_PER_SECOND + $duration->getNanos(); + } +} diff --git a/tests/Unit/Worker/Core/CoreOptionsTestCase.php b/tests/Unit/Worker/Core/CoreOptionsTestCase.php new file mode 100644 index 000000000..2fe787ea5 --- /dev/null +++ b/tests/Unit/Worker/Core/CoreOptionsTestCase.php @@ -0,0 +1,152 @@ + 'profile:7233', 'TEMPORAL_NAMESPACE' => 'profile-ns', 'TEMPORAL_API_KEY' => 'profile-key']; + + public static function provideOptions(): iterable + { + yield 'defaults' => [[], [], [], [ + 'address' => '127.0.0.1:7233', + 'namespace' => 'default', + 'apiKey' => null, + 'tls' => null, + 'maxCachedWorkflows' => 10_000, + 'grpcCompression' => 'gzip', + 'pollerAutoscaling' => true, + 'tuner' => null, + ]]; + yield 'arguments' => [[], [], [ + 'address' => 'arg:7233', + 'namespace' => 'arg-ns', + 'credentials' => ServiceCredentials::create()->withApiKey('own-key'), + ], ['address' => 'arg:7233', 'namespace' => 'arg-ns', 'apiKey' => 'own-key']]; + yield 'core environment' => [[], [ + CoreEnvironment::MAX_CACHED_WORKFLOWS => '0', + CoreEnvironment::GRPC_COMPRESSION => 'none', + CoreEnvironment::POLLER_AUTOSCALING => 'false', + ], [], [ + 'maxCachedWorkflows' => 0, + 'grpcCompression' => 'none', + 'pollerAutoscaling' => false, + ]]; + yield 'resource-based tuner defaults' => [[], [ + CoreEnvironment::TUNER_TARGET_MEMORY_USAGE => '0.8', + CoreEnvironment::TUNER_TARGET_CPU_USAGE => '0.9', + ], [], ['tuner' => [ + 'target_memory_usage' => 0.8, + 'target_cpu_usage' => 0.9, + 'workflow_slots' => ['min_slots' => 5, 'max_slots' => 500, 'ramp_throttle_ms' => 0], + 'activity_slots' => ['min_slots' => 1, 'max_slots' => 500, 'ramp_throttle_ms' => 50], + ]]]; + yield 'resource-based tuner slots' => [[], [ + CoreEnvironment::TUNER_TARGET_MEMORY_USAGE => '1', + CoreEnvironment::TUNER_TARGET_CPU_USAGE => '0', + CoreEnvironment::TUNER_WORKFLOW_MIN_SLOTS => '2', + CoreEnvironment::TUNER_WORKFLOW_MAX_SLOTS => '20', + CoreEnvironment::TUNER_WORKFLOW_RAMP_THROTTLE => '10', + CoreEnvironment::TUNER_ACTIVITY_MIN_SLOTS => '0', + CoreEnvironment::TUNER_ACTIVITY_MAX_SLOTS => '30', + CoreEnvironment::TUNER_ACTIVITY_RAMP_THROTTLE => '0', + ], [], ['tuner' => [ + 'target_memory_usage' => 1.0, + 'target_cpu_usage' => 0.0, + 'workflow_slots' => ['min_slots' => 2, 'max_slots' => 20, 'ramp_throttle_ms' => 10], + 'activity_slots' => ['min_slots' => 0, 'max_slots' => 30, 'ramp_throttle_ms' => 0], + ]]]; + yield 'profile' => [self::PROFILE_ENVIRONMENT, [], [], [ + 'address' => 'profile:7233', + 'namespace' => 'profile-ns', + 'apiKey' => 'profile-key', + ]]; + yield 'credentials key wins over the profile key' => [self::PROFILE_ENVIRONMENT, [], ['credentials' => ServiceCredentials::create()->withApiKey('own-key')], ['apiKey' => 'own-key']]; + yield 'empty credentials key keeps the profile key' => [self::PROFILE_ENVIRONMENT, [], ['credentials' => ServiceCredentials::create()], ['apiKey' => 'profile-key']]; + yield 'disabled tls' => [self::PROFILE_ENVIRONMENT + ['TEMPORAL_TLS' => 'false'], [], [], ['tls' => null]]; + } + + #[DataProvider('provideOptions')] + public function testCreate(array $environment, array $server, array $arguments, array $expected): void + { + $options = $this->create($environment, $server, $arguments); + + self::assertSame($expected, \array_intersect_key(\get_object_vars($options), $expected)); + } + + public function testProfileTlsIsKept(): void + { + $options = $this->create(self::PROFILE_ENVIRONMENT + ['TEMPORAL_TLS_SERVER_NAME' => 'server'], [], []); + + self::assertSame('server', $options->tls?->serverName); + } + + public static function provideMemoryLimits(): iterable + { + yield 'unlimited' => ['-1', 10_000]; + yield '2G is capped at the unlimited default' => ['2G', 10_000]; + yield '512M' => ['512M', 7_168]; + yield '128M' => ['128M', 1_024]; + yield '96M in bytes' => ['100663296', 512]; + yield '64M leaves nothing above the reserve' => ['64M', 10]; + yield '16M' => ['16M', 10]; + } + + #[DataProvider('provideMemoryLimits')] + public function testDefaultCacheSizeFollowsTheMemoryLimit(string $memoryLimit, int $expected): void + { + self::assertSame($expected, CoreOptions::defaultMaxCachedWorkflows($memoryLimit)); + } + + public function testMemoryLimitChangesOnlyTheDefaultCacheSize(): void + { + $previous = (string) \ini_get('memory_limit'); + self::assertNotFalse(\ini_set('memory_limit', '512M')); + try { + self::assertSame(7_168, $this->create([], [], [])->maxCachedWorkflows); + self::assertSame(20_000, $this->create([], [CoreEnvironment::MAX_CACHED_WORKFLOWS => '20000'], [])->maxCachedWorkflows); + } finally { + \ini_set('memory_limit', $previous); + } + } + + public function testTunerTargetsMustBeSetTogether(): void + { + $this->expectExceptionObject(new \InvalidArgumentException(CoreEnvironment::TUNER_TARGET_MEMORY_USAGE . ' and ' . CoreEnvironment::TUNER_TARGET_CPU_USAGE . ' must be set together')); + $this->create([], [CoreEnvironment::TUNER_TARGET_CPU_USAGE => '0.9'], []); + } + + private function create(array $environment, array $server, array $arguments): CoreOptions + { + $environment += ['TEMPORAL_CONFIG_FILE' => self::NO_DEFAULT_PROFILE]; + $previous = []; + foreach ($environment as $name => $value) { + $previous[$name] = \getenv($name); + \putenv("$name=$value"); + } + $_SERVER = $server + $_SERVER; + try { + return CoreOptions::create( + $arguments['address'] ?? null, + $arguments['namespace'] ?? null, + $arguments['credentials'] ?? null, + ); + } finally { + foreach ($previous as $name => $value) { + \putenv($value === false ? $name : "$name=$value"); + } + foreach (\array_keys($server) as $name) { + unset($_SERVER[$name]); + } + } + } +} diff --git a/tests/Unit/Worker/Core/CoreWorkerConfigTestCase.php b/tests/Unit/Worker/Core/CoreWorkerConfigTestCase.php new file mode 100644 index 000000000..be502396c --- /dev/null +++ b/tests/Unit/Worker/Core/CoreWorkerConfigTestCase.php @@ -0,0 +1,160 @@ + [CoreRole::All, WorkerOptions::new(), [ + 'namespace' => 'ns', + 'task_queue' => 'queue', + 'workflows' => true, + 'local_activities' => true, + 'remote_activities' => true, + 'deployment' => null, + 'graceful_shutdown_period_ms' => 0, + 'max_worker_activities_per_second' => null, + 'max_cached_workflows' => self::MAX_CACHED_WORKFLOWS, + 'max_outstanding_workflow_tasks' => 100, + 'max_outstanding_activities' => 1, + 'max_concurrent_workflow_task_polls' => 8, + 'sticky_queue_schedule_to_start_timeout_ms' => 5000, + 'max_concurrent_activity_task_polls' => 1, + 'nondeterminism_fails_workflow' => false, + 'max_heartbeat_throttle_interval_ms' => null, + 'poller_autoscaling' => true, + 'tuner' => null, + ]]; + yield 'activity role' => [CoreRole::Activity, WorkerOptions::new(), [ + 'workflows' => false, + 'remote_activities' => true, + 'max_cached_workflows' => 0, + 'max_outstanding_activities' => 1, + 'max_concurrent_activity_task_polls' => 1, + ]]; + yield 'workflow role' => [CoreRole::Workflow, WorkerOptions::new(), ['workflows' => true, 'remote_activities' => false, 'max_outstanding_activities' => 1]]; + yield 'disabled workflow worker' => [CoreRole::All, WorkerOptions::new()->withDisableWorkflowWorker(true), ['workflows' => false, 'local_activities' => false, 'max_cached_workflows' => 0]]; + yield 'local activities only' => [CoreRole::All, WorkerOptions::new()->withLocalActivityWorkerOnly(true), ['remote_activities' => false]]; + yield 'explicit options' => [CoreRole::All, WorkerOptions::new() + ->withWorkerStopTimeout(5) + ->withStickyScheduleToStartTimeout(2) + ->withMaxHeartbeatThrottleInterval(3) + ->withMaxConcurrentWorkflowTaskExecutionSize(1) + ->withMaxConcurrentWorkflowTaskPollers(1) + ->withMaxConcurrentActivityTaskPollers(3) + ->withWorkerActivitiesPerSecond(5.0) + ->withWorkflowPanicPolicy(WorkflowPanicPolicy::FailWorkflow) + ->withDeploymentOptions(WorkerDeploymentOptions::new()->withUseVersioning(true)->withVersion('deployment.build')), [ + 'deployment' => ['UseVersioning' => true, 'Version' => ['DeploymentName' => 'deployment', 'BuildId' => 'build'], 'DefaultVersioningBehavior' => 0], + 'graceful_shutdown_period_ms' => 5000, + 'max_worker_activities_per_second' => 5.0, + 'max_outstanding_workflow_tasks' => 2, + 'max_concurrent_workflow_task_polls' => 2, + 'sticky_queue_schedule_to_start_timeout_ms' => 2000, + 'max_concurrent_activity_task_polls' => 3, + 'nondeterminism_fails_workflow' => true, + 'max_heartbeat_throttle_interval_ms' => 3000, + ]]; + } + + public static function provideTuners(): iterable + { + yield 'activity slots capped at one activity' => [CoreRole::Activity, ['min_slots' => 3, 'max_slots' => 500, 'ramp_throttle_ms' => 50], ['min_slots' => 1, 'max_slots' => 1, 'ramp_throttle_ms' => 50]]; + yield 'no minimum activity slots' => [CoreRole::All, ['min_slots' => 0, 'max_slots' => 4, 'ramp_throttle_ms' => 50], ['min_slots' => 0, 'max_slots' => 1, 'ramp_throttle_ms' => 50]]; + } + + #[DataProvider('provideTuners')] + public function testTunerRunsOneActivityAtATime(CoreRole $role, array $activitySlots, array $expected): void + { + $workflowSlots = ['min_slots' => 5, 'max_slots' => 500, 'ramp_throttle_ms' => 0]; + $tuner = ['target_memory_usage' => 0.8, 'target_cpu_usage' => 0.9, 'workflow_slots' => $workflowSlots, 'activity_slots' => $activitySlots]; + $config = self::config(self::options(tuner: $tuner)); + + $build = $config->build(self::worker(WorkerOptions::new()), $role); + + self::assertSame(\array_replace($tuner, ['activity_slots' => $expected]), $build['tuner']); + } + + #[DataProvider('provideBuilds')] + public function testBuild(CoreRole $role, WorkerOptions $workerOptions, array $expected): void + { + $config = self::config(self::options()); + + $build = $config->build(self::worker($workerOptions), $role); + + self::assertSame($expected, \array_intersect_key($build, $expected)); + } + + public static function provideConnections(): iterable + { + yield 'plain with process identity' => [null, WorkerOptions::new(), 'updated-key', [ + 'target_url' => 'http://host:7233', + 'tls' => null, + 'client_name' => SdkVersion::SDK_NAME, + 'identity' => \getmypid() . '@' . \gethostname(), + 'api_key' => 'updated-key', + 'grpc_compression' => 'gzip', + ]]; + yield 'tls with identity and no key' => [new ConfigTls(serverName: 'server'), WorkerOptions::new()->withIdentity('me'), '', [ + 'target_url' => 'https://host:7233', + 'tls' => ['server_root_ca_cert' => null, 'domain' => 'server', 'client_cert' => null, 'client_private_key' => null], + 'identity' => 'me', + 'api_key' => null, + ]]; + } + + #[DataProvider('provideConnections')] + public function testConnection(?ConfigTls $tls, WorkerOptions $workerOptions, string $apiKey, array $expected): void + { + $connection = self::config(self::options(tls: $tls))->connection(self::worker($workerOptions), $apiKey); + + self::assertSame($expected, \array_intersect_key($connection, $expected)); + } + + private static function options(?ConfigTls $tls = null, ?array $tuner = null): CoreOptions + { + $construct = \Closure::bind(static fn(mixed ...$arguments): CoreOptions => new CoreOptions(...$arguments), null, CoreOptions::class); + + return $construct( + address: 'host:7233', + namespace: 'ns', + apiKey: 'key', + tls: $tls, + maxCachedWorkflows: self::MAX_CACHED_WORKFLOWS, + grpcCompression: 'gzip', + pollerAutoscaling: true, + tuner: $tuner, + ); + } + + private static function config(CoreOptions $options): CoreWorkerConfig + { + return new CoreWorkerConfig($options, new Marshaller(new AttributeMapperFactory(new AttributeReader()))); + } + + private static function worker(WorkerOptions $options): WorkerInterface + { + return CoreWorkerFactory::create()->newWorker('queue', $options); + } +} diff --git a/tests/Unit/Worker/Core/PatchVersionsTestCase.php b/tests/Unit/Worker/Core/PatchVersionsTestCase.php new file mode 100644 index 000000000..ec2519009 --- /dev/null +++ b/tests/Unit/Worker/Core/PatchVersionsTestCase.php @@ -0,0 +1,61 @@ +notify('my-change-3'); + $commands = []; + + self::assertSame(3, $patches->version('my-change', 1, 5, true, $commands)); + self::assertSame('my-change-3', $commands[0]->getSetPatchMarker()->getPatchId()); + } + + public function testForeignPatchIdsAreIgnored(): void + { + $patches = new PatchVersions(); + $patches->notify('core-patch'); + $patches->notify('nodash'); + $commands = []; + + self::assertSame(-1, $patches->version('core', -1, 5, true, $commands)); + self::assertSame(-1, $patches->version('nodash', -1, 5, true, $commands)); + self::assertSame([], $commands); + } + + public function testNewExecutionTakesMaxSupportedOnce(): void + { + $patches = new PatchVersions(); + $commands = []; + + self::assertSame(2, $patches->version('change', 1, 2, false, $commands)); + self::assertSame(2, $patches->version('change', 1, 2, false, $commands)); + self::assertCount(1, $commands); + } + + public static function provideUnsupportedVersions(): iterable + { + yield 'removed version' => ['change-1', 'Workflow code removed support of version 1 for "change" changeID. The oldest supported version is 2']; + yield 'too new version' => ['change-4', 'Workflow code is too old to support version 4 for "change" changeID. The maximum supported version is 3']; + } + + #[DataProvider('provideUnsupportedVersions')] + public function testVersionOutsideSupportedRangeFails(string $notified, string $message): void + { + $patches = new PatchVersions(); + $patches->notify($notified); + $commands = []; + + $this->expectExceptionObject(new \LogicException($message)); + $patches->version('change', 2, 3, true, $commands); + } +} diff --git a/tests/Unit/Worker/Core/PayloadMapperTestCase.php b/tests/Unit/Worker/Core/PayloadMapperTestCase.php new file mode 100644 index 000000000..597f1911a --- /dev/null +++ b/tests/Unit/Worker/Core/PayloadMapperTestCase.php @@ -0,0 +1,78 @@ +failure(new \RuntimeException("bad \xff byte")); + + self::assertSame('bad ? byte', $failure->getMessage()); + self::assertSame('PHP_SDK', $failure->getSource()); + self::assertSame(\RuntimeException::class, $failure->getApplicationFailureInfo()->getType()); + } + + public function testConverterErrorsAreNotHidden(): void + { + $resource = \fopen('php://memory', 'rb'); + $error = new ApplicationFailure('message', 'type', false, EncodedValues::fromValues([$resource])); + + try { + $this->expectException(DataConverterException::class); + (new PayloadMapper(DataConverter::createDefault()))->failure($error); + } finally { + \fclose($resource); + } + } + + public function testPlainExceptionFromTheConverterIsNotHidden(): void + { + $converter = new class implements DataConverterInterface { + public function fromPayload(Payload $payload, mixed $type): mixed + { + return null; + } + + public function toPayload(mixed $value): Payload + { + throw new \Exception('converter broke'); + } + }; + $error = new ApplicationFailure('message', 'type', false, EncodedValues::fromValues(['detail'])); + + $this->expectExceptionMessage('converter broke'); + (new PayloadMapper($converter))->failure($error); + } + + public function testFirstPayloadOfMissingOrEmptyValuesIsNull(): void + { + $mapper = new PayloadMapper(DataConverter::createDefault()); + + self::assertNull($mapper->firstPayload(null)); + self::assertNull($mapper->firstPayload(EncodedValues::empty())); + self::assertSame('a', $mapper->decode($mapper->firstPayload($mapper->encode(['a', 'b'])))); + } + + public function testFailureRoundTripKeepsTheApplicationFailure(): void + { + $mapper = new PayloadMapper(DataConverter::createDefault()); + + $exception = $mapper->exception($mapper->failure(new ApplicationFailure('broken', 'Type', true))); + + self::assertInstanceOf(ApplicationFailure::class, $exception); + self::assertSame('Type', $exception->getType()); + self::assertTrue($exception->isNonRetryable()); + } +} diff --git a/tests/Unit/Worker/Core/ProtoTimeTestCase.php b/tests/Unit/Worker/Core/ProtoTimeTestCase.php new file mode 100644 index 000000000..f30f07e10 --- /dev/null +++ b/tests/Unit/Worker/Core/ProtoTimeTestCase.php @@ -0,0 +1,69 @@ + [0, null]; + yield 'missing' => [null, null]; + yield 'nanoseconds' => [1_500_000_000, [1, 500_000_000]]; + yield 'array' => [['seconds' => 2, 'nanos' => 3], [2, 3]]; + yield 'empty array' => [[], [0, 0]]; + } + + #[DataProvider('provideOptionalDurations')] + public function testOptionalDuration(int|array|null $value, ?array $expected): void + { + $duration = ProtoTime::optionalDuration($value); + + self::assertSame($expected, $duration === null ? null : [(int) $duration->getSeconds(), $duration->getNanos()]); + } + + public function testIntervalsAreCachedClones(): void + { + $interval = ProtoTime::interval(new Duration(['seconds' => 5])); + $interval->addSeconds(1); + + self::assertSame(5.0, ProtoTime::interval(new Duration(['seconds' => 5]))->totalSeconds); + self::assertSame(0.0, ProtoTime::interval(null)->totalSeconds); + } + + public function testIntervalCacheIsResetWhenFull(): void + { + for ($seconds = 1; $seconds <= self::INTERVAL_CACHE_SIZE + 1; ++$seconds) { + self::assertSame((float) $seconds, ProtoTime::interval(new Duration(['seconds' => $seconds]))->totalSeconds); + } + } + + public function testMicroseconds(): void + { + $before = (int) (new \DateTimeImmutable())->format('Uu'); + + self::assertGreaterThanOrEqual($before, ProtoTime::micros(null)); + self::assertSame(2_000_003, ProtoTime::micros(new Timestamp(['seconds' => 2, 'nanos' => 3_999]))); + self::assertSame(1_500_000, ProtoTime::microsOf(new Duration(['seconds' => 1, 'nanos' => 500_000_000]))); + self::assertSame(0, ProtoTime::microsOf(null)); + self::assertSame('1970-01-01T00:00:02.000003+00:00', ProtoTime::utc(2_000_003)->format('Y-m-d\TH:i:s.uP')); + } + + public function testDateTimes(): void + { + $zone = new \DateTimeZone('Europe/Berlin'); + $time = ProtoTime::dateTime(ProtoTime::timestamp(new \DateTimeImmutable('@1700000000')), $zone); + + self::assertSame([1_700_000_000, 'Europe/Berlin'], [$time->getTimestamp(), $time->getTimezone()->getName()]); + self::assertSame('Europe/Berlin', ProtoTime::dateTime(null, $zone)->getTimezone()->getName()); + } +} diff --git a/tests/Unit/Worker/Core/RunStateTestCase.php b/tests/Unit/Worker/Core/RunStateTestCase.php new file mode 100644 index 000000000..b090279fc --- /dev/null +++ b/tests/Unit/Worker/Core/RunStateTestCase.php @@ -0,0 +1,21 @@ +bind(10, RunState::TIMER); + + self::assertSame(10, $run->release($seq)); + $this->expectExceptionObject(new \OutOfBoundsException("Unknown command sequence $seq in run run")); + $run->requestId($seq); + } +} diff --git a/tests/Unit/Worker/Core/WorkflowActivationsTestCase.php b/tests/Unit/Worker/Core/WorkflowActivationsTestCase.php new file mode 100644 index 000000000..514e28002 --- /dev/null +++ b/tests/Unit/Worker/Core/WorkflowActivationsTestCase.php @@ -0,0 +1,514 @@ + [ + [[new WorkflowActivationJob(['initialize_workflow' => new InitializeWorkflow([ + 'workflow_type' => 'Wf', + 'workflow_id' => 'wf-id', + 'arguments' => [self::payload('arg')], + 'last_completion_result' => new Payloads(['payloads' => [self::payload('last')]]), + ])])]], + static fn(): array => [], + static function (array $completions, array $dispatched): void { + self::assertSame(self::VERSIONING_BEHAVIOR, $completions[0]->getSuccessful()->getVersioningBehavior()); + self::assertSame('StartWorkflow', $dispatched[0]->getName()); + self::assertSame(1, $dispatched[0]->getOptions()['lastCompletion']); + self::assertSame(['arg', 'last'], [$dispatched[0]->getPayloads()->getValue(0), $dispatched[0]->getPayloads()->getValue(1)]); + }, + ]; + yield 'timer fires' => [ + [[self::initialize()], [self::job('fire_timer', new FireTimer(['seq' => 1]))]], + self::onStart(static fn(): array => [new Request('NewTimer', ['ms' => 1000])]), + static function (array $completions, array $dispatched): void { + self::assertInstanceOf(SuccessResponse::class, $dispatched[1]); + }, + ]; + yield 'local activity backoff and retry' => [ + [ + [self::initialize()], + [self::job('resolve_activity', new ResolveActivity(['seq' => 1, 'result' => new ActivityResolution(['backoff' => new DoBackoff([ + 'attempt' => 2, + 'backoff_duration' => new Duration(['seconds' => 3]), + ])])]))], + [self::job('fire_timer', new FireTimer(['seq' => 2]))], + ], + self::onStart(static fn(): array => [new Request('ExecuteLocalActivity', ['name' => 'Local', 'options' => []])]), + static function (array $completions, array $dispatched): void { + $timer = $completions[1]->getSuccessful()->getCommands()[0]->getStartTimer(); + self::assertSame([2, 3], [$timer->getSeq(), (int) $timer->getStartToFireTimeout()->getSeconds()]); + $retry = $completions[2]->getSuccessful()->getCommands()[0]->getScheduleLocalActivity(); + self::assertSame([3, 2], [$retry->getSeq(), $retry->getAttempt()]); + self::assertCount(1, $dispatched); + }, + ]; + yield 'activity completed' => [ + [[self::initialize()], [self::resolveActivity(new ActivityResolution(['completed' => new ActivitySuccess(['result' => self::payload('done')])]))]], + self::onStart(static fn(): array => [new Request('ExecuteActivity', ['name' => 'Act', 'options' => []])]), + static function (array $completions, array $dispatched): void { + self::assertSame('done', $dispatched[1]->getPayloads()->getValue(0)); + }, + ]; + yield 'activity failed' => [ + [[self::initialize()], [self::resolveActivity(new ActivityResolution(['failed' => new ActivityFailure(['failure' => self::applicationFailure('broken')])]))]], + self::onStart(static fn(): array => [new Request('ExecuteActivity', ['name' => 'Act', 'options' => []])]), + static function (array $completions, array $dispatched): void { + self::assertSame('broken', $dispatched[1]->getFailure()->getOriginalMessage()); + }, + ]; + yield 'try-cancel activity cancelled' => [ + [[self::initialize()], [self::resolveActivity(self::cancelledActivity())]], + self::onStart(static fn(): array => [new Request('ExecuteActivity', ['name' => 'Act', 'options' => []])]), + static function (array $completions, array $dispatched): void { + self::assertInstanceOf(CanceledFailure::class, $dispatched[1]->getFailure()); + }, + ]; + yield 'wait-cancel activity cancelled' => [ + [[self::initialize()], [self::resolveActivity(self::cancelledActivity())]], + self::onStart(static fn(): array => [new Request('ExecuteActivity', ['name' => 'Act', 'options' => ['WaitForCancellation' => true]])]), + static function (array $completions, array $dispatched): void { + self::assertInstanceOf(ActivityFailureException::class, $dispatched[1]->getFailure()); + }, + ]; + yield 'signal' => [ + [[self::job('signal_workflow', new SignalWorkflow(['signal_name' => 'go', 'input' => [self::payload('x')]]))]], + static fn(): array => [], + static function (array $completions, array $dispatched): void { + self::assertSame(['InvokeSignal', 'go', 'x'], [$dispatched[0]->getName(), $dispatched[0]->getOptions()['name'], $dispatched[0]->getPayloads()->getValue(0)]); + }, + ]; + yield 'query answered' => [ + [[self::initialize(), self::query()]], + self::onQuery(static fn(string $id): array => [new SuccessClientResponse($id, EncodedValues::fromValues(['answer']))]), + static function (array $completions): void { + self::assertSame('"answer"', self::queryResult($completions[0])->getSucceeded()->getResponse()->getData()); + }, + ]; + yield 'query failed' => [ + [[self::initialize(), self::query()]], + self::onQuery(static fn(string $id): array => [new FailedClientResponse($id, new \RuntimeException('no answer'))]), + static function (array $completions): void { + self::assertSame('no answer', self::queryResult($completions[0])->getFailed()->getMessage()); + }, + ]; + yield 'query without result' => [ + [[self::initialize(), self::query()]], + self::onQuery(static fn(): array => []), + static function (array $completions): void { + self::assertSame('Query produced no result', self::queryResult($completions[0])->getFailed()->getMessage()); + }, + ]; + yield 'cancel workflow' => [ + [[self::job('cancel_workflow', new CancelWorkflow())]], + static fn(): array => [], + static function (array $completions, array $dispatched): void { + self::assertSame('CancelWorkflow', $dispatched[0]->getName()); + }, + ]; + yield 'update' => [ + [[self::job('do_update', new DoUpdate(['id' => 'upd', 'protocol_instance_id' => 'protocol', 'name' => 'set', 'run_validator' => false]))]], + static fn(): array => [], + static function (array $completions, array $dispatched): void { + self::assertSame(['runId' => 'run-id', 'updateId' => 'upd', 'name' => 'set', 'replay' => true], $dispatched[0]->getOptions()); + }, + ]; + yield 'notified patch' => [ + [[self::job('notify_has_patch', new NotifyHasPatch(['patch_id' => 'change-3'])), self::initialize()]], + self::onStart(static fn(): array => [new Request('GetVersion', ['changeID' => 'change', 'minSupported' => 1, 'maxSupported' => 5])]), + static function (array $completions, array $dispatched): void { + self::assertSame(3, $dispatched[1]->getPayloads()->getValue(0)); + }, + ]; + yield 'child start cancelled' => [ + [[self::initialize()], [self::job('resolve_child_workflow_execution_start', new ResolveChildWorkflowExecutionStart([ + 'seq' => 1, + 'cancelled' => new ResolveChildWorkflowExecutionStartCancelled(['failure' => self::canceledFailure()]), + ]))]], + self::onStart(static fn(): array => [new Request('ExecuteChildWorkflow', ['name' => 'Child', 'options' => []])]), + static function (array $completions, array $dispatched): void { + self::assertInstanceOf(CanceledFailure::class, $dispatched[1]->getFailure()); + }, + ]; + yield 'child failed' => [ + [[self::initialize()], [self::childStarted()], [self::childResult(new ChildWorkflowResult(['failed' => new ChildFailure(['failure' => self::applicationFailure('child broken')])]))]], + self::onStart(static fn(): array => [new Request('ExecuteChildWorkflow', ['name' => 'Child', 'options' => []])]), + static function (array $completions, array $dispatched): void { + self::assertSame('child broken', $dispatched[\count($dispatched) - 1]->getFailure()->getOriginalMessage()); + }, + ]; + yield 'child cancelled' => [ + [[self::initialize()], [self::childStarted()], [self::childResult(new ChildWorkflowResult(['cancelled' => new ChildCancellation(['failure' => self::canceledFailure()])]))]], + self::onStart(static fn(): array => [new Request('ExecuteChildWorkflow', ['name' => 'Child', 'options' => []])]), + static function (array $completions, array $dispatched): void { + self::assertInstanceOf(CanceledFailure::class, $dispatched[\count($dispatched) - 1]->getFailure()); + }, + ]; + yield 'external signal delivered' => [ + [[self::initialize()], [self::job('resolve_signal_external_workflow', new ResolveSignalExternalWorkflow(['seq' => 1]))]], + self::onStart(static fn(): array => [new Request('SignalExternalWorkflow', ['namespace' => '', 'workflowID' => 'wf', 'runID' => null, 'signal' => 's', 'childWorkflowOnly' => false])]), + static function (array $completions, array $dispatched): void { + self::assertInstanceOf(SuccessResponse::class, $dispatched[1]); + }, + ]; + yield 'external cancel failed' => [ + [[self::initialize()], [self::job('resolve_request_cancel_external_workflow', new ResolveRequestCancelExternalWorkflow(['seq' => 1, 'failure' => self::applicationFailure('not found')]))]], + self::onStart(static fn(): array => [new Request('CancelExternalWorkflow', ['namespace' => '', 'workflowID' => 'wf', 'runID' => null])]), + static function (array $completions, array $dispatched): void { + self::assertSame('not found', $dispatched[1]->getFailure()->getOriginalMessage()); + }, + ]; + yield 'eviction' => [ + [[self::initialize()], [self::job('remove_from_cache', new RemoveFromCache())]], + static fn(): array => [], + static function (array $completions, array $dispatched): void { + self::assertSame('DestroyWorkflow', $dispatched[1]->getName()); + self::assertCount(0, $completions[1]->getSuccessful()->getCommands()); + }, + ]; + yield 'random seed update' => [ + [[self::job('update_random_seed', new UpdateRandomSeed())]], + static fn(): array => [], + static function (array $completions, array $dispatched): void { + self::assertSame('successful', $completions[0]->getStatus()); + self::assertSame([], $dispatched); + }, + ]; + yield 'unknown job' => [ + [[new WorkflowActivationJob()]], + static fn(): array => [], + static function (array $completions): void { + self::assertSame('Unsupported activation job ""', $completions[0]->getFailed()->getFailure()->getMessage()); + }, + ]; + } + + /** + * @param list> $activations + */ + #[DataProvider('provideActivations')] + public function testHandle(array $activations, \Closure $dispatch, \Closure $assert): void + { + $dispatched = []; + $handler = new WorkflowActivations(DataConverter::createDefault(), static function (array $messages) use ($dispatch, &$dispatched): array { + \array_push($dispatched, ...$messages); + return $dispatch($messages); + }, 'ns', 'queue', ['Wf' => self::VERSIONING_BEHAVIOR], false); + + $completions = \array_map(fn(array $jobs): WorkflowActivationCompletion => $this->complete($handler, $jobs), $activations); + + $assert($completions, $dispatched); + } + + public function testTypedSearchAttributesUseMetadataNames(): void + { + $commands = $this->start([new Request('UpsertWorkflowTypedSearchAttributes', ['search_attributes' => [ + 'Kw' => ['type' => ValueType::Keyword->value, 'operation' => 'set', 'value' => 'v'], + 'Num' => ['type' => ValueType::Float->value, 'operation' => 'set', 'value' => 1.5], + 'Gone' => ['type' => ValueType::Int->value, 'operation' => 'unset'], + ]])]); + + $fields = $commands[0]->getUpsertWorkflowSearchAttributes()->getSearchAttributes()->getIndexedFields(); + self::assertSame('Keyword', $fields['Kw']->getMetadata()['type']); + self::assertSame('Double', $fields['Num']->getMetadata()['type']); + self::assertFalse(isset($fields['Gone']->getMetadata()['type'])); + } + + public function testUnknownSearchAttributeTypeFailsTheActivation(): void + { + $completion = $this->complete($this->activations(static fn(array $messages): array => $messages[0] instanceof ServerRequest + ? [new Request('UpsertWorkflowTypedSearchAttributes', ['search_attributes' => [ + 'Bad' => ['type' => 'decimal', 'operation' => 'set', 'value' => 1], + ]])] + : []), [self::initialize()]); + + self::assertSame('failed', $completion->getStatus()); + self::assertStringContainsString('"decimal"', $completion->getFailed()->getFailure()->getMessage()); + } + + public function testNegativeTimerFailsLikeRoadRunner(): void + { + $responses = []; + $this->complete($this->activations(static function (array $messages) use (&$responses): array { + if ($messages[0] instanceof ServerRequest) { + return [new Request('NewTimer', ['ms' => -5])]; + } + \array_push($responses, ...$messages); + return []; + }), [self::initialize()]); + + self::assertCount(1, $responses); + self::assertInstanceOf(FailureResponse::class, $responses[0]); + self::assertInstanceOf(ApplicationFailure::class, $responses[0]->getFailure()); + self::assertSame('negative duration provided -5ms', $responses[0]->getFailure()->getOriginalMessage()); + } + + public function testZeroTimerResolvesWithoutCommand(): void + { + $responses = []; + $completion = $this->complete($this->activations(static function (array $messages) use (&$responses): array { + if ($messages[0] instanceof ServerRequest) { + return [new Request('NewTimer', ['ms' => 0])]; + } + \array_push($responses, ...$messages); + return []; + }), [self::initialize()]); + + self::assertCount(0, $completion->getSuccessful()->getCommands()); + self::assertInstanceOf(SuccessResponse::class, $responses[0]); + } + + public function testLocalActivityWaitsForCancellationCompleted(): void + { + $commands = $this->start([new Request('ExecuteLocalActivity', ['name' => 'Act', 'options' => []])]); + + self::assertSame( + ActivityCancellationType::WAIT_CANCELLATION_COMPLETED, + $commands[0]->getScheduleLocalActivity()->getCancellationType(), + ); + } + + public static function provideChildWaitForCancellation(): iterable + { + yield 'default' => [[]]; + yield 'try cancel' => [['WaitForCancellation' => false]]; + yield 'wait for cancellation' => [['WaitForCancellation' => true]]; + } + + #[DataProvider('provideChildWaitForCancellation')] + public function testChildWaitsForCancellationCompletedLikeRoadRunner(array $options): void + { + $commands = $this->start([new Request('ExecuteChildWorkflow', ['name' => 'Child', 'options' => $options])]); + + self::assertSame( + ChildWorkflowCancellationType::WAIT_CANCELLATION_COMPLETED, + $commands[0]->getStartChildWorkflowExecution()->getCancellationType(), + ); + } + + public function testChildStartFailureAnswersWaitersAndReleasesTheChild(): void + { + $run = new RunState('run'); + $seq = $run->bind(10, RunState::CHILD); + $run->startChild(10, 'child-id'); + $run->awaitChildExecution(10, 11); + + $messages = $this->resolutions()->childStarted($run, new ResolveChildWorkflowExecutionStart([ + 'seq' => $seq, + 'failed' => new ResolveChildWorkflowExecutionStartFailure(['workflow_id' => 'child-id', 'workflow_type' => 'Child']), + ]), new TickInfo(new \DateTimeImmutable())); + + self::assertSame([10, 11], \array_map(static fn(CommandInterface $message): int => $message->getID(), $messages)); + self::assertInstanceOf(ChildWorkflowFailure::class, $messages[0]->getFailure()); + self::assertSame([[], [], []], $this->childState($run)); + } + + public function testChildResultReleasesTheStartedChild(): void + { + $run = new RunState('run'); + $seq = $run->bind(10, RunState::CHILD); + $run->startChild(10, 'child-id'); + $resolutions = $this->resolutions(); + $tick = new TickInfo(new \DateTimeImmutable()); + + $resolutions->childStarted($run, new ResolveChildWorkflowExecutionStart([ + 'seq' => $seq, + 'succeeded' => new ResolveChildWorkflowExecutionStartSuccess(['run_id' => 'child-run']), + ]), $tick); + self::assertSame(['child-id', 'child-run'], $run->childExecution(10)); + + $resolutions->childResult($run, new ResolveChildWorkflowExecution([ + 'seq' => $seq, + 'result' => new ChildWorkflowResult(['completed' => new ChildSuccess()]), + ]), $tick); + + self::assertSame([[], [], []], $this->childState($run)); + } + + private static function payload(string $value): Payload + { + return DataConverter::createDefault()->toPayload($value); + } + + private static function job(string $variant, object $job): WorkflowActivationJob + { + return new WorkflowActivationJob([$variant => $job]); + } + + private static function resolveActivity(ActivityResolution $result): WorkflowActivationJob + { + return self::job('resolve_activity', new ResolveActivity(['seq' => 1, 'result' => $result])); + } + + private static function applicationFailure(string $message): Failure + { + return new Failure(['message' => $message, 'application_failure_info' => new ApplicationFailureInfo()]); + } + + private static function canceledFailure(): Failure + { + return new Failure(['message' => 'canceled', 'canceled_failure_info' => new CanceledFailureInfo()]); + } + + private static function cancelledActivity(): ActivityResolution + { + return new ActivityResolution(['cancelled' => new ActivityCancellation(['failure' => new Failure([ + 'message' => 'activity canceled', + 'activity_failure_info' => new ActivityFailureInfo(['activity_type' => new ActivityType(['name' => 'Act'])]), + 'cause' => self::canceledFailure(), + ])])]); + } + + private static function childStarted(): WorkflowActivationJob + { + return self::job('resolve_child_workflow_execution_start', new ResolveChildWorkflowExecutionStart([ + 'seq' => 1, + 'succeeded' => new ResolveChildWorkflowExecutionStartSuccess(['run_id' => 'child-run']), + ])); + } + + private static function childResult(ChildWorkflowResult $result): WorkflowActivationJob + { + return self::job('resolve_child_workflow_execution', new ResolveChildWorkflowExecution(['seq' => 1, 'result' => $result])); + } + + private static function query(): WorkflowActivationJob + { + return self::job('query_workflow', new QueryWorkflow(['query_id' => 'q1', 'query_type' => 'state'])); + } + + private static function queryResult(WorkflowActivationCompletion $completion): QueryResult + { + return $completion->getSuccessful()->getCommands()[0]->getRespondToQuery(); + } + + private static function onStart(\Closure $commands): \Closure + { + return static fn(array $messages): array => $messages[0] instanceof ServerRequest && $messages[0]->getName() === 'StartWorkflow' ? $commands() : []; + } + + private static function onQuery(\Closure $responses): \Closure + { + return static fn(array $messages): array => $messages[0] instanceof ServerRequest && $messages[0]->getName() === 'InvokeQuery' ? $responses($messages[0]->getID()) : []; + } + + private static function initialize(): WorkflowActivationJob + { + return new WorkflowActivationJob(['initialize_workflow' => new InitializeWorkflow([ + 'workflow_type' => 'Wf', + 'workflow_id' => 'wf-id', + 'attempt' => 1, + ])]); + } + + /** + * @param list $commands + * @return list + */ + private function start(array $commands): array + { + $completion = $this->complete($this->activations( + static fn(array $messages): array => $messages[0] instanceof ServerRequest ? $commands : [], + ), [self::initialize()]); + self::assertSame('successful', $completion->getStatus(), $completion->serializeToJsonString()); + + return \iterator_to_array($completion->getSuccessful()->getCommands()); + } + + /** + * @param list $jobs + */ + private function complete(WorkflowActivations $activations, array $jobs): WorkflowActivationCompletion + { + $activation = new WorkflowActivation(['run_id' => 'run-id', 'jobs' => $jobs]); + $completion = new WorkflowActivationCompletion(); + $completion->mergeFromString($activations->handle($activation->serializeToString())); + + return $completion; + } + + private function activations(\Closure $dispatch): WorkflowActivations + { + return new WorkflowActivations(DataConverter::createDefault(), $dispatch, 'ns', 'queue', [], false); + } + + private function resolutions(): ResolutionMapper + { + return new ResolutionMapper(new PayloadMapper(DataConverter::createDefault()), 'ns'); + } + + private function childState(RunState $run): array + { + return (fn(): array => [$this->childWorkflowIds, $this->childExecutions, $this->childWaiters])->call($run); + } +} diff --git a/tests/Unit/WorkerFactory/RoadRunnerDispatchTestCase.php b/tests/Unit/WorkerFactory/RoadRunnerDispatchTestCase.php new file mode 100644 index 000000000..588d08775 --- /dev/null +++ b/tests/Unit/WorkerFactory/RoadRunnerDispatchTestCase.php @@ -0,0 +1,53 @@ +createMock(RPCConnectionInterface::class)); + $factory->newWorker(self::TASK_QUEUE); + $host = $this->createMock(HostConnectionInterface::class); + $host->method('waitBatch')->willReturnOnConsecutiveCalls( + new CommandBatch('[{"command":"GetWorkerInfo"},{"id":7,"payloads":""}]', []), + new CommandBatch('[{"command":"GetWorkerInfo"}]', ['taskQueue' => self::TASK_QUEUE]), + new CommandBatch('[{"command":"GetWorkerInfo"}]', ['taskQueue' => 'missing']), + null, + ); + $frames = []; + $host->method('send')->willReturnCallback(static function (string $frame) use (&$frames): void { + $frames[] = \json_decode($frame, true, flags: \JSON_THROW_ON_ERROR); + }); + $host->expects($this->never())->method('error'); + + $this->assertSame(0, $factory->run($host)); + + $this->assertCount(3, $frames); + $this->assertStringContainsString(self::TASK_QUEUE, \base64_decode($frames[0][0]['payloads'])); + $this->assertSame('Got the response to undefined request 7', $frames[0][1]['options']['message']); + $this->assertSame('Method "GetWorkerInfo" is not registered', self::failureMessage($frames[1][0])); + $this->assertSame('Cannot find a worker for task queue "missing"', self::failureMessage($frames[2][0])); + } + + private static function failureMessage(array $response): string + { + $failure = new Failure(); + $failure->mergeFromString(\base64_decode($response['failure'])); + + return $failure->getMessage(); + } +}