diff --git a/.github/pr-test-selection.json b/.github/pr-test-selection.json index 02c142420..f3e329153 100644 --- a/.github/pr-test-selection.json +++ b/.github/pr-test-selection.json @@ -11,12 +11,17 @@ "tests/Unit/V2/CancellationContextTest.php", "tests/Unit/V2/CompiledWorkflowDefinitionTest.php", "tests/Unit/V2/EmbeddedHttpSnapshotTest.php", + "tests/Unit/V2/ExecutionTracingTest.php", "tests/Unit/V2/ExternalPayloadStorageTest.php", "tests/Unit/V2/PlatformProtocolSpecsTest.php", "tests/Unit/V2/ReadmeBranchDestinationsTest.php", "tests/Unit/V2/ScopedCancellationContextTest.php", "tests/Unit/V2/SqliteTransactionTest.php", "tests/Unit/V2/StartOptionsTest.php", + "tests/Unit/V2/TaskTraceTimingsTest.php", + "tests/Unit/V2/TaskTraceCarrierTest.php", + "tests/Unit/V2/TraceCarrierTest.php", + "tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php", "tests/Unit/V2/VersionMarkerWireFormatTest.php", "tests/Unit/V2/WorkflowFiberRunnerTest.php", "tests/Unit/V2/WorkflowHistoryPayloadCastTest.php", diff --git a/.github/tracing-http/bootstrap.php b/.github/tracing-http/bootstrap.php new file mode 100644 index 000000000..b8fe5411c --- /dev/null +++ b/.github/tracing-http/bootstrap.php @@ -0,0 +1,6 @@ + ./result.json +``` + +The reader requires all 20 cells: three baseline/disabled/enabled repetitions +for each workload and one final baseline per workload. It refuses a failed +cell, missing role, changed container or resource limit, decreasing CPU, +missing samples, nonzero child exit, OOM/restart, unfinished business work or +span loss. It normalizes CPU by measured completions, excluding warmup counts. +Metadata, span and audit totals explicitly include warmup. + +The declared offer window is 120 seconds for the original method and 300 for +the prospective v2 method. Node timers use integer-delay +precision, so the probe's actual elapsed value can be fractionally below its +nominal window. The reader preserves that value and requires every cgroup's +counter interval to cover the full declared window, as well as all offers and +original completions. It does not round or rescale measured CPU. + +Run from the copied experiment's parent directory. The input root must resolve +inside the working directory. Each data file must resolve inside the selected +experiment root, including symbolic links. This keeps input selection scoped +to the data mounted for analysis. + +Both fixed methods use paired relative differences and a conventional two-sided +95% paired-t interval: the original three pairs use two degrees of freedom and +critical value 4.3026527299; v2's five pairs use four degrees of freedom and +2.7764451052. These small samples rely on the usual +t-interval assumptions. A target passes only when both its interval upper +bound and absolute final baseline drift meet the previously declared limit. +Do not select extra repetitions after inspecting the result. + +Memory means and container sample peaks describe the observed sampling +interval, not RSS or an exact simultaneous peak. Worker peak fields are the +sum of the four individual container peaks. Lifetime peaks include startup +and warmup. Review the readable result, then delete the copied raw data. diff --git a/.github/tracing-performance/RESULTS-2026-10-17.md b/.github/tracing-performance/RESULTS-2026-10-17.md new file mode 100644 index 000000000..124f32857 --- /dev/null +++ b/.github/tracing-performance/RESULTS-2026-10-17.md @@ -0,0 +1,179 @@ +# Embedded tracing measurement, October 17, 2026 + +All 20 original cells completed and passed their business, resource and export +audits. **None of the four CPU-budget criteria qualifies.** This is an unreleased +embedded candidate measurement, with no stable, service-mode or capacity claim. + +## Method and software + +The [method was recorded before results](https://github.com/durable-workflow/workflow/issues/773#issuecomment-6228453784). +The [functional and offered-load prerequisites](https://github.com/durable-workflow/workflow/issues/773#issuecomment-6228327954) +are separate observations. This batch ran three rotating matched repetitions +of published Workflow 2.5.28, candidate tracing disabled, and candidate tracing +enabled. Two final published-baseline cells checked drift. Every cell used fresh +application/database state and the same four workers, logging call, dependency +references, resource limits and local accepting collector. + +Measured candidate: `316316a827fadc7b5aa545842451ca9c8e7a9db5`, tree +`c5ac80029a831e2664200e8b58aa301e657053dd`. Published source matched all 606 +files at tag `592dee`; other dependency references matched the candidate. +This compares the complete candidate with the published package. It does not +isolate every candidate change as a causal tracing cost. + +The dedicated amd64 host had eight logical CPUs, about 16 GiB RAM, AMD EPYC-Rome +as observed, Microsoft hypervisor, Linux 6.8.0-146 and cgroup v2. Docker was +29.1.3, Compose 2.40.3, PHP 8.3.35 with pcntl, Redis extension 6.1.0 and CLI +OPcache disabled. Builds and dependency installation finished before measurement. +Administrative SSH/status reads and one bounded first-cell reader-validation +copy occurred during the batch. No concurrent build or unrelated application +workload was admitted. This is not a matched hardware performance claim against +another product or an estimate of maximum capacity. + +| Role | CPU cap | Memory cap | +| --- | ---: | ---: | +| HTTP application | 0.5 | 512 MiB | +| Each of four workers | 1 | 384 MiB | +| MySQL | 1 | 2 GiB | +| Redis | 0.1 | 128 MiB | +| Collector | 0.2 | 256 MiB | +| Probe | 0.2 | 256 MiB | + +Swap was disabled. The primary quantity is the sum of exact CPU counter deltas +from all nine containers, divided by measured completed workflows. It includes +application, all workers, database, cache, collector and probe through child +completion/provider drain. Setup, warmup, audit queries, host sampling tools and +teardown are outside those deltas. Every container identity, cap and counter +interval was checked before/after. No OOM, restart or nonzero child exit occurred. + +Ordinary cells offered one one-activity workflow per second for 120 seconds, +120 measured completions. Fan-out cells offered one 64-activity workflow per +minute for 120 seconds, two measured completions. Separate warmups completed +30 ordinary workflows or one fan-out workflow before measurement. Original +90-second observer, 100-second run and 120-second execution bounds were retained. +Providers were installed in every cell. The application registered the log tap +only in the enabled profile, as described in the [recipe](README.md). + +## CPU acceptance + +Each estimate is the mean of three matched candidate/baseline ratios, with a +two-sided 95% paired-t interval, n=3, df=2. The frozen acceptance requires both +the upper interval endpoint and absolute final baseline drift to fit the target. +Negative point estimates do not demonstrate a speed improvement. + +| Workload | Profile | Mean CPU change | 95% interval | Baseline drift | Target | Qualified | +| --- | --- | ---: | --- | ---: | ---: | --- | +| Ordinary | Disabled | -1.28% | [-6.15%, +3.60%] | +0.30% | 1% | No | +| Ordinary | Enabled | +6.26% | [+3.16%, +9.36%] | +0.30% | 5% | No | +| 64-member fan-out | Disabled | -1.28% | [-8.27%, +5.70%] | +4.10% | 1% | No | +| 64-member fan-out | Enabled | +0.43% | [-12.20%, +13.07%] | +4.10% | 5% | No | + +All CPU observations below are seconds per completed measured workflow. The +three values are original repetitions, with no discarded cell or extra sample. + +| Workload | Profile | Repetition 1 | Repetition 2 | Repetition 3 | Final baseline drift cell | +| --- | --- | ---: | ---: | ---: | ---: | +| Ordinary | Published | 0.605717 | 0.596319 | 0.636958 | 0.614822 | +| Ordinary | Disabled | 0.595531 | 0.601429 | 0.617848 | | +| Ordinary | Enabled | 0.647830 | 0.638079 | 0.667641 | | +| Fan-out | Published | 84.775585 | 90.481546 | 92.066782 | 92.759297 | +| Fan-out | Disabled | 85.815143 | 89.805320 | 88.088379 | | +| Fan-out | Enabled | 90.109925 | 88.509247 | 89.471048 | | + +### Component CPU + +Arithmetic means across the three repetitions, seconds per completed workflow. +Worker means sum all four worker containers. + +| Workload/profile | HTTP | Workers | MySQL | Redis | Collector | Probe | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Ordinary/published | 0.101366 | 0.244040 | 0.220823 | 0.023822 | 0.000072 | 0.022874 | +| Ordinary/disabled | 0.099768 | 0.240792 | 0.218220 | 0.023648 | 0.000074 | 0.022434 | +| Ordinary/enabled | 0.109262 | 0.256221 | 0.220766 | 0.023732 | 0.018796 | 0.022405 | +| Fan-out/published | 2.725616 | 66.573568 | 17.606948 | 1.236480 | 0.004065 | 0.961294 | +| Fan-out/disabled | 2.695288 | 65.571619 | 17.429256 | 1.236462 | 0.004066 | 0.966255 | +| Fan-out/enabled | 2.719067 | 66.332326 | 17.513380 | 1.235851 | 0.586929 | 0.975852 | + +Ordinary enabled CPU is higher in HTTP, workers and the collector. The collector +accounts for about half the difference between the raw enabled and published +means. That identifies a useful investigation, without establishing which +source change or exporter setting causes the full observed difference. + +## Latency, memory and correctness + +All declared offers were accepted and completed, with no missed offer or HTTP +error. Per-cell observed completion rates were about 0.9960–1.00001 workflows/s +for ordinary work and 0.0166665–0.0166668 for fan-out. These are offered-load +results. Fan-out has only two measured observations per cell, so its high +percentiles describe those few observations, not a reliable tail distribution. + +Each latency entry is the arithmetic mean of three per-cell percentiles, in +milliseconds. These are not pooled percentiles. + +| Workload/profile | Mean p50 | Mean p95 | Mean p99 | +| --- | ---: | ---: | ---: | +| Ordinary/published | 887.50 | 1524.28 | 1688.58 | +| Ordinary/disabled | 891.34 | 1434.45 | 1698.46 | +| Ordinary/enabled | 915.68 | 1457.40 | 1629.62 | +| Fan-out/published | 54319.66 | 55497.18 | 55497.18 | +| Fan-out/disabled | 53638.97 | 55474.13 | 55474.13 | +| Fan-out/enabled | 53690.74 | 55916.89 | 55916.89 | + +Memory below is sampled cgroup memory in MiB: repetition-mean / largest sampled +sum of individual container peaks. Worker values sum four containers. Peaks +are not simultaneous aggregate peaks or PHP RSS. Requested sampling was one +second; observed intervals over all 20 cells were 1.288–1.461 seconds. Samples +can miss short peaks. Container lifetime peaks also include startup/warmup and +are excluded from this table. + +| Workload/profile | HTTP | Workers | MySQL | Redis | Collector | Probe | +| --- | --- | --- | --- | --- | --- | --- | +| Ordinary/published | 34.20 / 34.50 | 194.53 / 206.57 | 565.99 / 579.05 | 3.46 / 5.05 | 8.32 / 8.32 | 21.53 / 30.57 | +| Ordinary/disabled | 34.32 / 34.63 | 195.36 / 207.56 | 566.83 / 579.21 | 3.56 / 5.94 | 8.32 / 8.32 | 21.52 / 26.44 | +| Ordinary/enabled | 36.90 / 37.69 | 207.73 / 220.63 | 565.98 / 580.74 | 3.53 / 5.14 | 13.76 / 14.02 | 21.09 / 30.49 | +| Fan-out/published | 34.15 / 34.66 | 221.51 / 233.35 | 577.20 / 591.85 | 3.49 / 5.21 | 8.33 / 8.33 | 19.49 / 30.63 | +| Fan-out/disabled | 34.27 / 34.54 | 222.89 / 234.96 | 578.72 / 592.33 | 3.50 / 6.04 | 8.33 / 8.34 | 19.68 / 29.73 | +| Fan-out/enabled | 36.84 / 37.38 | 235.36 / 247.54 | 579.47 / 592.66 | 3.48 / 5.03 | 13.79 / 13.80 | 19.28 / 24.55 | + +Including warmup and drift cells, the audit counts 1,530 runs, 3,420 activities +and callbacks, 6,480 first-attempt tasks and 14,850 business events. Typed results, +history counts and identities match. No unfinished run/task/attempt/activity, +non-first task/attempt, residual queue or failed job remains. The summed task +`repair_count` is **375**, all in fan-out cells; do not describe this batch as +zero repair/redispatch work. Fan-out repetition counters are published +32/45/44, disabled 35/36/34 and enabled 41/30/38, plus 40 in the final baseline. +Every ordinary cell has zero. This work remains included in measured CPU. + +All enabled spans ended and were received: 4,456 / 4,456, including warmup. +Ordinary enabled cells each have 1,050 spans, 21,750 bytes of run metadata and +143,400 bytes of task metadata. Fan-out enabled cells have 440/429/437 spans, +435 bytes of run metadata and 102,813 bytes of task metadata each. These are +stored observability-field byte counts, not total database size or network +traffic. Activity-execution metadata is zero. Disabled/published cells produce +no spans or stored carrier metadata. No durable business payload/history field +was replaced by telemetry. + +## Reader correction and remaining limits + +The original analyzer rejected three actual callback elapsed values: +119.999219339, 119.999356686 and 119.999713308 seconds. Their original drivers +completed with all declared offers and results. Node's integer timer precision +does not guarantee an exact callback time. The corrected reader retains those +actual values, verifies the observation covers the final declared offer, and +requires **every cgroup counter interval to cover the full 120-second window**. +It neither rounds elapsed data nor rescales CPU. The new controls failed on the +prior reader and pass in the corrected 24-test analysis/probe suite. Original +samples, workloads, CPU targets and statistical method are unchanged. No cell +was rerun or relabeled as another original. + +An earlier functional baseline completed business work but failed probe teardown; +it remains unsuccessful. A separate corrected lifecycle control passed before +this batch. This batch's original drivers all completed successfully. The +experiment host, key and raw data are disposable; the code and this readable +result are the retained outcome. + +The encoded-baggage unit failure remains selected against the released OTel +dependency. Full tracing, migration/catalog integration and consumer acceptance +remain unfinished. These measurements do not authorize a release. Next source +work should investigate the enabled ordinary execution/export cost and improve +measurement sensitivity before considering a new, separately declared study. +No adaptive extra repetitions or relaxed targets are part of this result. diff --git a/.github/tracing-performance/RESULTS-2026-10-20.md b/.github/tracing-performance/RESULTS-2026-10-20.md new file mode 100644 index 000000000..5380d2287 --- /dev/null +++ b/.github/tracing-performance/RESULTS-2026-10-20.md @@ -0,0 +1,113 @@ +# Embedded tracing measurement, October 20, 2026 + +All 20 original cells passed their workload, resource, exit and official +Collector drain gates. **None of the four CPU budgets qualifies.** These results +do not qualify a release or maximum service capacity. The failed October 17 +study remains unchanged in [its original report](RESULTS-2026-10-17.md). + +## Fixed method + +The [method was declared before admission](https://github.com/durable-workflow/workflow/issues/773#issuecomment-6294532491). +Candidate `b2d394c5c3e16484c798f3e7a7293bfb001b07eb`, tree +`37651d791f918a80fab9d19cc31d408a9504599e`, was compared with normally installed +published Core **2.7.3**, `dda951d93f58b82447348f1b3b4e4e17dba149fb`. +All other Composer package references matched. No later diagnostic correction +was inserted into this source or measurement. + +The isolated amd64 host had eight logical AMD EPYC-Genoa CPUs, approximately +16 GiB RAM, Microsoft virtualization, Linux 6.8.0-146, cgroup v2 and no swap. +Docker was 29.1.3, Compose 2.40.3, PHP 8.3.35 with CLI OPcache disabled, +Laravel 13.37, OpenTelemetry API 1.10.1, SDK 1.15.0 and OTLP exporter 1.4.0. +Builds and installation completed before admission. No unrelated application +workload or build was run during the study. Bounded administrative SSH reads +continued. The host and all experiment state were retired after results. + +The accepting official Collector was +`otel/opentelemetry-collector:0.163.1@sha256:f0e23036dce3db3f9cb80d907c75a0d034cd32e93511a1943482d46129b9ae9c`. +The application used the existing four-span queue, two-span batch and 100 ms +transport timeout; Collector counters independently matched ended spans after +complete provider drain. Export failure observations did not change, and +refused/receiver-failed counters remained zero. No span payloads were retained. + +Each cell used a fresh database/application, four workers and identical caps: +HTTP application 0.5 CPU / 512 MiB; each worker 1 CPU / 384 MiB; MySQL 1 CPU / +2 GiB; Redis 0.1 CPU / 128 MiB; Collector and probe each 0.2 CPU / 256 MiB. +The primary quantity sums exact cgroup CPU deltas for these nine containers +through business completion and export drain. Setup, warmup, audit queries, +sampling tools and teardown are outside those deltas. The one-second requested +sampling interval was approximately 1.26 seconds in practice; exact CPU counters +do not depend on that interval. Memory observations are sampled, not allocation +or simultaneous-peak measurements. + +Ordinary cells offered one one-activity workflow per second for 120 seconds +(120 measured completions), following 30 warmups. Fan-out cells offered one +64-activity workflow per minute for 120 seconds (two measured completions), +following one warmup. Original observer/run/execution bounds remained +90/100/120 seconds. Repetitions rotated base/off/on, on/base/off, off/on/base, +then repeated the baseline for drift, separately for each workload. No extra +repetition was selected after results. + +## CPU acceptance + +Estimates use the mean of three paired candidate/baseline ratios, two-sided +95% paired-t intervals, n=3, df=2. Both the upper endpoint and absolute final +baseline drift must fit the declared budget. Negative estimates do not prove +an improvement. The budgets remain unchanged for this study. + +| Workload | Profile | Mean | 95% interval | Baseline drift | Budget | Qualified | +| --- | --- | ---: | --- | ---: | ---: | --- | +| Ordinary | Disabled | +1.11% | [-7.40%, +9.62%] | +1.62% | 1% | No | +| Ordinary | Enabled | +9.46% | [+1.35%, +17.56%] | +1.62% | 5% | No | +| 64-member fan-out | Disabled | -0.59% | [-13.91%, +12.72%] | +2.04% | 1% | No | +| 64-member fan-out | Enabled | +3.18% | [-7.85%, +14.21%] | +2.04% | 5% | No | + +Every original CPU observation below is seconds per completed measured workflow. + +| Workload | Repetition | Published baseline | Disabled | Enabled | +| --- | --- | ---: | ---: | ---: | +| Ordinary | 1 | 0.440138 | 0.450680 | 0.487149 | +| Ordinary | 2 | 0.433282 | 0.421278 | 0.484975 | +| Ordinary | 3 | 0.439790 | 0.456113 | 0.465107 | +| Ordinary | Drift | 0.444807 | — | — | +| Fan-out | 1 | 48.130522 | 47.228357 | 50.872936 | +| Fan-out | 2 | 47.496107 | 45.026346 | 46.572337 | +| Fan-out | 3 | 44.848782 | 47.221256 | 47.447672 | +| Fan-out | Drift | 47.780681 | — | — | + +## Other observations + +All offered workflows were accepted and completed. The independent reader +validated exact callback/history counts and results, no missed offers, extra +attempts, unfinished work, failed jobs or residual queue entries. No container +OOM/restart or nonzero worker child exit occurred. MySQL, Redis and the official +Collector exited zero; HTTP and retaining shells used their declared 143 stop. +Repairs remained observable and were not substituted for extra attempts. + +| Workload/profile | Observed p95 range | Observed p99 range | Sum of sampled container peaks | +| --- | --- | --- | --- | +| Ordinary/base | 1.131–1.371 s | 1.386–1.655 s | 919,670,784–924,098,560 bytes | +| Ordinary/off | 1.134–1.379 s | 1.374–1.401 s | 922,849,280–924,983,296 bytes | +| Ordinary/on | 1.388–1.393 s | 1.399–1.458 s | 944,701,440–949,186,560 bytes | +| Fan-out/base | 28.756–29.613 s | 28.756–29.613 s | 964,190,208–969,908,224 bytes | +| Fan-out/off | 28.866–29.791 s | 28.866–29.791 s | 963,702,784–971,960,320 bytes | +| Fan-out/on | 28.771–31.492 s | 28.771–31.492 s | 982,581,248–988,864,512 bytes | + +Fan-out tails have only two measured observations per cell. These ranges are +descriptive, without a throughput or latency regression claim. Maximum offer +lag across cells was 74.66 ms, below the original 100 ms admission gate. + +Disabled and baseline cells persisted zero tracing bytes and ended/exported +zero spans. Enabled ordinary cells, including warmup, each persisted 21,750 run +bytes and 143,400 task bytes and ended/accepted/exported exactly 1,050 spans. +Enabled fan-out cells persisted 435 run bytes and 102,813–102,814 task bytes; +their ended/accepted/exported counts were respectively 413, 403 and 401. +No activity-execution carrier bytes were stored. Different task processing and +repair observations account for differing physical span counts; the reader +verified business cardinality separately. + +The prerequisites also passed: functional baseline/off/on (two runs, 65 +activities each) and the published-baseline offered-load check (120 ordinary +plus two fan-out workflows). These are workload checks, not maximum-capacity +estimates. The next qualification must name its exact changed source and +published baseline, declare its method and budgets before admission, and retain +these original failures. Changing a target cannot turn this study into a pass. diff --git a/.github/tracing-performance/analyze.mjs b/.github/tracing-performance/analyze.mjs new file mode 100644 index 000000000..fddd214f5 --- /dev/null +++ b/.github/tracing-performance/analyze.mjs @@ -0,0 +1,301 @@ +import assert from 'node:assert/strict'; +import {COLLECTOR_IMAGE, collectorDelta} from './collector-metrics.mjs'; +import {readdir, readFile, realpath} from 'node:fs/promises'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; + +const roles = {app: [1, 50000, 512], worker: [4, 100000, 384], mysql: [1, 100000, 2048], + redis: [1, 10000, 128], collector: [1, 20000, 256], probe: [1, 20000, 256]}; +const legacyMethod = 'embedded-120s-3pairs-v1'; +const methods = { + [legacyMethod]: {repetitions: ['1', '2', '3'], seconds: 120, critical: 4.3026527299, + targets: {off: 0.01, on: 0.05}, label: 'paired-t-95-n3-df2; targets and drift frozen before admission'}, + 'embedded-300s-5pairs-v2': {repetitions: ['1', '2', '3', '4', '5'], seconds: 300, critical: 2.7764451052, + targets: {off: 0.05, on: 0.15}, label: 'paired-t-95-n5-df4; prospective 5%/15% targets and drift'}, +}; + +function studyMethod(id = legacyMethod) { + assert.ok(Object.hasOwn(methods, id), 'Unknown study method'); + return methods[id]; +} + +function counters(text) { + return Object.fromEntries(text.trim().split('\n').map(line => { + const [key, value] = line.trim().split(/\s+/); + const parsed = Number(value); + assert.ok(Number.isSafeInteger(parsed) && parsed >= 0, `Invalid ${key} counter`); + return [key, parsed]; + })); +} + +function snapshot(rows) { + assert.equal(rows.length, 9, 'All nine roles must be readable'); + const counts = {}; + const result = new Map(); + for (const row of rows) { + assert.ok(Object.hasOwn(roles, row.role), 'Unknown runtime role'); + const [, quota, memory] = roles[row.role]; + assert.match(row.container, /^[a-f0-9]{64}$/); + assert.ok(!result.has(row.container), 'Duplicate container'); + assert.deepEqual(row.cpu_max.trim().split(/\s+/).map(Number), [quota, 100000]); + assert.equal(Number(row.memory_max), memory * 1024 * 1024); + assert.equal(Number(row.swap_max), 0); + const memoryEvents = counters(row.memory_events); + assert.equal(memoryEvents.oom, 0); + assert.equal(memoryEvents.oom_kill, 0); + const cpu = counters(row.cpu); + assert.ok(Object.hasOwn(cpu, 'usage_usec') && Object.hasOwn(cpu, 'throttled_usec')); + assert.ok(Number.isFinite(Date.parse(row.sampled_at)), 'Missing sample timestamp'); + assert.ok(Number.isSafeInteger(row.memory_current_bytes) && row.memory_current_bytes >= 0); + assert.ok(Number.isSafeInteger(row.lifetime_memory_peak_bytes) && row.lifetime_memory_peak_bytes >= 0); + result.set(row.container, {...row, counters: cpu}); + counts[row.role] = (counts[row.role] ?? 0) + 1; + } + for (const [role, [count]] of Object.entries(roles)) assert.equal(counts[role], count); + return result; +} + +function validateBusiness(data) { + const {outcome, measure, warmup, audit} = data; + const method = studyMethod(outcome.study_method); + assert.equal(outcome.result, 0, 'An unsuccessful original is not a CPU sample'); + assert.equal(outcome.stage, 'complete'); + assert.ok(['base', 'off', 'on'].includes(outcome.cell)); + assert.ok(['ordinary', 'fanout'].includes(outcome.workload)); + const ordinary = outcome.workload === 'ordinary'; + assert.ok(method.repetitions.includes(outcome.repeat) || (outcome.repeat === 'drift' && outcome.cell === 'base'), + 'Unexpected repetition'); + if (outcome.study_method === 'embedded-300s-5pairs-v2') { + assert.equal(outcome.collector, 'otelcol', 'The new method requires the official Collector'); + } + const expected = ordinary ? method.seconds : method.seconds / 60; + const members = ordinary ? 1 : 64; + for (const [probe, count] of [[measure, expected], [warmup, ordinary ? 30 : 1]]) { + assert.equal(probe.offered_target, count); + for (const key of ['offered', 'accepted', 'completed']) assert.equal(probe[key], count); + assert.equal(probe.members, members); + assert.equal(probe.observer_seconds, 90); + assert.equal(probe.missed_offers, 0); + assert.equal(probe.http_errors, 0); + assert.deepEqual(probe.failures, []); + assert.equal(probe.queue_wake_enabled, true); + assert.equal(probe.queue_driver, 'database'); + assert.equal(probe.diagnostic_only, false); + } + assert.equal(measure.duration_seconds, method.seconds); + assert.equal(measure.rate, ordinary ? 1 : 1 / 60); + const lastOfferSeconds = (expected - 1) / measure.rate; + assert.ok(Number.isFinite(measure.elapsed_seconds) && measure.elapsed_seconds >= lastOfferSeconds); + assert.ok([measure.p50_ms, measure.p95_ms, measure.p99_ms].every(value => Number.isFinite(value) && value > 0)); + assert.ok(measure.p50_ms <= measure.p95_ms && measure.p95_ms <= measure.p99_ms); + const runs = expected + (ordinary ? 30 : 1); + assert.equal(audit.runs, runs); + assert.equal(audit.activities, runs * members); + assert.equal(audit.callbacks, runs * members); + assert.equal(audit.tasks, runs * (members + 2)); + assert.deepEqual(audit.history_types, {StartAccepted: runs, WorkflowStarted: runs, + ActivityScheduled: runs * members, ActivityStarted: runs * members, + ActivityCompleted: runs * members, WorkflowCompleted: runs}); + assert.ok(Number.isSafeInteger(audit.repairs) && audit.repairs >= 0); + assert.deepEqual(new Set(Object.keys(audit.metadata_bytes)), new Set(['activity_executions', 'workflow_runs', 'workflow_tasks'])); + for (const bytes of Object.values(audit.metadata_bytes)) assert.ok(Number.isSafeInteger(bytes) && bytes >= 0); + for (const key of ['unfinished_runs', 'unfinished_activities', 'unfinished_tasks', 'nonfirst_tasks', + 'unfinished_attempts', 'nonfirst_attempts', 'queue', 'failed_jobs']) assert.equal(audit[key], 0, key); + return expected; +} + +function validateTelemetry({outcome, audit, telemetry, before}) { + if (outcome.collector === 'otelcol') { + assert.equal(outcome.collector_image, COLLECTOR_IMAGE, 'Unexpected Collector image'); + assert.equal(telemetry.received, collectorDelta(telemetry.collector), 'Collector count proof differs'); + const collectorRows = before.filter(row => row.role === 'collector'); + assert.equal(collectorRows.length, 1, 'Missing Collector CPU role'); + assert.equal(telemetry.collector.before.container_prefix, collectorRows[0].container.slice(0, 12), + 'Counter proof belongs to another Collector CPU role'); + } else { + assert.equal(outcome.collector, undefined, 'Unknown collector profile'); + assert.equal(telemetry.collector, undefined, 'Official proof requires its declared profile'); + } + assert.equal(telemetry.ended, telemetry.received, 'Span loss invalidates the cell'); + assert.ok(Number.isSafeInteger(telemetry.ended) && telemetry.ended >= 0); + if (outcome.cell === 'on') { + assert.ok(telemetry.ended > 0); + assert.ok(audit.metadata_bytes.workflow_runs > 0 && audit.metadata_bytes.workflow_tasks > 0); + } else { + assert.equal(telemetry.ended, 0); + for (const bytes of Object.values(audit.metadata_bytes)) assert.equal(bytes, 0); + } +} + +function summarizeComponents(data, before, after) { + assert.deepEqual(new Set(before.keys()), new Set(after.keys()), 'Container replacement invalidates CPU'); + const samples = data.samples; + assert.ok(samples.length >= 18 && samples.length % 9 === 0, 'Incomplete sampler output'); + const components = {}; + const observedIntervals = []; + for (const [id, initial] of before) { + const final = after.get(id); + assert.equal(final.role, initial.role); + const delta = final.counters.usage_usec - initial.counters.usage_usec; + const throttle = final.counters.throttled_usec - initial.counters.throttled_usec; + assert.ok(delta >= 0 && throttle >= 0, 'Decreasing CPU counter'); + const component = components[initial.role] ??= {cpu_seconds: 0, throttled_seconds: 0, + sum_container_sampled_peaks_bytes: 0, sampled_memory_mean_bytes: 0, sum_container_lifetime_peaks_bytes: 0}; + component.cpu_seconds += delta / 1e6; + component.throttled_seconds += throttle / 1e6; + const observations = samples.filter(row => row.container === id); + assert.equal(observations.length, samples.length / 9, 'Sampler lost a role'); + const values = observations.map(row => row.memory_current_bytes); + assert.ok(values.every(value => Number.isSafeInteger(value) && value >= 0)); + component.sum_container_sampled_peaks_bytes += Math.max(...values); + component.sampled_memory_mean_bytes += values.reduce((sum, value) => sum + value, 0) / values.length; + component.sum_container_lifetime_peaks_bytes += final.lifetime_memory_peak_bytes; + const times = observations.map(row => Date.parse(row.sampled_at)); + const intervals = times.slice(1).map((value, index) => value - times[index]); + assert.ok(intervals.every(value => value > 0), 'Invalid sampling order'); + observedIntervals.push(...intervals); + const intervalMs = Date.parse(final.sampled_at) - Date.parse(initial.sampled_at); + assert.ok(intervalMs >= data.measure.duration_seconds * 1000, 'CPU interval must cover the offer window'); + } + return {components, observedIntervals}; +} + +function validateExits(data, before) { + assert.equal(data.workerExits.length, 4); + assert.equal(data.probeExits.length, 2); + for (const code of [...data.workerExits, ...data.probeExits]) assert.equal(code, 0); + assert.equal(data.exits.length, 9); + assert.deepEqual(new Set(data.exits.map(item => item.id)), new Set(before.keys())); + for (const item of data.exits) { + assert.equal(item.oom, false); + assert.equal(item.restarts, 0); + const role = before.get(item.id).role; + const graceful = ['mysql', 'redis'].includes(role) + || (role === 'collector' && data.outcome.collector === 'otelcol'); + assert.equal(item.exit, graceful ? 0 : 143); + } +} + +export function summarizeCell(data) { + const expected = validateBusiness(data); + validateTelemetry(data); + const before = snapshot(data.before); + const after = snapshot(data.after); + validateExits(data, before); + const {components, observedIntervals} = summarizeComponents(data, before, after); + const {outcome, measure, audit, telemetry} = data; + const cpuSeconds = Object.values(components).reduce((sum, item) => sum + item.cpu_seconds, 0); + assert.ok(cpuSeconds > 0); + return {...outcome, cpu_seconds: cpuSeconds, cpu_seconds_per_workflow: cpuSeconds / expected, + php_cpu_seconds: components.app.cpu_seconds + components.worker.cpu_seconds, components, + completed_per_offer_second: expected / measure.duration_seconds, + completed_per_observed_second: expected / measure.elapsed_seconds, + measured: measure, audit_including_warmup: audit, spans_including_warmup: telemetry, + sample_interval_ms: {min: Math.min(...observedIntervals), max: Math.max(...observedIntervals), + mean: observedIntervals.reduce((sum, value) => sum + value, 0) / observedIntervals.length}}; +} + +function interval(values, method) { + const count = method.repetitions.length; + assert.equal(values.length, count); + const mean = values.reduce((sum, value) => sum + value, 0) / count; + const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (count - 1); + const margin = method.critical * Math.sqrt(variance / count); + return {mean, lower: mean - margin, upper: mean + margin}; +} + +export function compare(cells) { + const methodId = cells[0]?.study_method ?? legacyMethod; + const method = studyMethod(methodId); + for (const cell of cells) assert.equal(cell.study_method ?? legacyMethod, methodId, 'Mixed study methods'); + assert.equal(cells.length, 2 * (3 * method.repetitions.length + 1), + methodId === legacyMethod ? 'All twenty declared cells are required' : 'All thirty-two declared cells are required'); + if (methodId !== legacyMethod) { + assert.equal(cells[0].collector, 'otelcol', 'The new method requires the official Collector'); + for (const key of ['candidate', 'baseline']) { + assert.match(cells[0][key] ?? '', /^[a-f0-9]{40}$/, 'Missing exact source identity'); + for (const cell of cells) assert.equal(cell[key], cells[0][key], 'Mixed source identities'); + } + } + const collectors = new Set(cells.map(cell => cell.collector ?? 'legacy-php')); + assert.equal(collectors.size, 1, 'Collector profiles cannot be mixed'); + assert.ok(['legacy-php', 'otelcol'].includes([...collectors][0]), 'Unknown collector profile'); + if (collectors.has('otelcol')) { + for (const cell of cells) assert.equal(cell.collector_image, COLLECTOR_IMAGE, 'Collector image drift'); + } + const result = {}; + for (const workload of ['ordinary', 'fanout']) { + const select = (cell, repeat) => { + const matches = cells.filter(row => row.workload === workload && row.cell === cell && row.repeat === repeat); + assert.equal(matches.length, 1, 'Missing or repeated matched cell'); + assert.ok(Number.isFinite(matches[0].cpu_seconds_per_workflow) && matches[0].cpu_seconds_per_workflow > 0); + return matches[0].cpu_seconds_per_workflow; + }; + const baseline = method.repetitions.map(repeat => select('base', repeat)); + const meanBase = baseline.reduce((sum, value) => sum + value, 0) / baseline.length; + const drift = select('base', 'drift') / meanBase - 1; + result[workload] = {baseline_cpu_seconds_per_workflow: baseline, baseline_drift_ratio: drift}; + for (const [cell, target] of Object.entries(method.targets)) { + const paired = method.repetitions.map((repeat, index) => select(cell, repeat) / baseline[index] - 1); + const confidence = interval(paired, method); + result[workload][cell] = {paired_ratios: paired, confidence_95: confidence, target, + qualified: confidence.upper <= target && Math.abs(drift) <= target}; + } + } + return result; +} + +function within(directory, resolved) { + if (resolved !== directory && !resolved.startsWith(directory + path.sep)) { + throw new RangeError('Experiment input escapes its selected directory'); + } + return resolved; +} + +export async function experimentRoot(input, directory = process.cwd()) { + const allowed = await realpath(directory); + return within(allowed, await realpath(path.resolve(allowed, input))); +} + +export async function dataPath(root, file) { + return within(root, await realpath(file)); +} + +const json = async (root, file) => JSON.parse(await readFile(await dataPath(root, file), 'utf8')); +const jsonLines = async (root, file) => (await readFile(await dataPath(root, file), 'utf8')) + .trim().split('\n').map(line => JSON.parse(line)); + +export async function loadCell(root, name) { + root = await experimentRoot(root); + assert.match(name, /^tracing-measure-(base|off|on)-(ordinary|fanout)-(1|2|3|4|5|drift)$/); + const proof = await dataPath(root, path.join(root, 'proof', name)); + const state = await dataPath(root, path.join(root, 'state', name)); + const exitCodes = async directory => { + const files = (await readdir(await dataPath(root, directory))).filter(file => file.endsWith('.exit')); + return Promise.all(files.map(async file => { + const value = (await readFile(await dataPath(root, path.join(directory, file)), 'utf8')).trim(); + assert.match(value, /^(0|[1-9]\d*)$/, 'Missing or invalid child exit'); + return Number(value); + })); + }; + const summary = summarizeCell({outcome: await json(root, path.join(proof, 'outcome.json')), + measure: await json(root, path.join(proof, 'measure-result.json')), warmup: await json(root, path.join(proof, 'warmup-result.json')), + audit: await json(root, path.join(proof, 'audit.json')), telemetry: await json(root, path.join(proof, 'telemetry.json')), + before: await jsonLines(root, path.join(proof, 'before.jsonl')), after: await jsonLines(root, path.join(proof, 'after.jsonl')), + samples: await jsonLines(root, path.join(proof, 'samples.jsonl')), exits: await jsonLines(root, path.join(proof, 'exits.jsonl')), + workerExits: await exitCodes(path.join(state, 'runtime')), probeExits: await exitCodes(path.join(state, 'results'))}); + assert.equal(name, `tracing-measure-${summary.cell}-${summary.workload}-${summary.repeat}`, + 'Cell name and declared identity differ'); + return summary; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const input = process.argv[2]; + assert.ok(input, 'Usage: node analyze.mjs COPIED_EXPERIMENT_ROOT'); + const root = await experimentRoot(input); + const names = (await readdir(await dataPath(root, path.join(root, 'proof')))) + .filter(name => name.startsWith('tracing-measure-')); + const cells = await Promise.all(names.map(name => loadCell(root, name))); + console.log(JSON.stringify({method: studyMethod(cells[0]?.study_method).label, + comparison: compare(cells), cells}, null, 2)); +} diff --git a/.github/tracing-performance/analyze.test.mjs b/.github/tracing-performance/analyze.test.mjs new file mode 100644 index 000000000..9bef1c344 --- /dev/null +++ b/.github/tracing-performance/analyze.test.mjs @@ -0,0 +1,397 @@ +import assert from 'node:assert/strict'; +import {mkdir, mkdtemp, rm, symlink, writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import {compare, dataPath, experimentRoot, summarizeCell} from './analyze.mjs'; +import {COLLECTOR_IMAGE, COLLECTOR_VERSION, collectorDelta, parseCollectorMetrics, readCollectorSnapshot} from './collector-metrics.mjs'; + +function pairData() { + const cells = []; + for (const workload of ['ordinary', 'fanout']) { + for (const repeat of ['1', '2', '3']) { + for (const [cell, ratio] of [['base', 1], ['off', 1.002], ['on', 1.02]]) { + cells.push({workload, repeat, cell, cpu_seconds_per_workflow: 10 * ratio}); + } + } + cells.push({workload, repeat: 'drift', cell: 'base', cpu_seconds_per_workflow: 10}); + } + return cells; +} + +function longPairData() { + const cells = pairData(); + for (const workload of ['ordinary', 'fanout']) { + for (const repeat of ['4', '5']) { + for (const cell of ['base', 'off', 'on']) { + cells.push({workload, repeat, cell, cpu_seconds_per_workflow: 10}); + } + } + } + return cells.map(cell => ({...cell, study_method: 'embedded-300s-5pairs-v2', collector: 'otelcol', + collector_image: COLLECTOR_IMAGE, candidate: 'a'.repeat(40), baseline: 'b'.repeat(40), + cpu_seconds_per_workflow: cell.cell === 'off' ? 10.2 : cell.cell === 'on' ? 11 : 10})); +} + +test('the prospective method requires all five pairs and uses its declared interval and targets', () => { + const cells = longPairData(); + const values = [10.8, 10.9, 11, 11.1, 11.2]; + for (const [index, repeat] of ['1', '2', '3', '4', '5'].entries()) { + cells.find(row => row.workload === 'ordinary' && row.cell === 'on' && row.repeat === repeat) + .cpu_seconds_per_workflow = values[index]; + } + const result = compare(cells).ordinary; + assert.equal(result.off.target, 0.05); + assert.equal(result.on.target, 0.15); + assert.ok(Math.abs(result.on.confidence_95.mean - 0.1) < 1e-12); + assert.ok(Math.abs(result.on.confidence_95.upper - (0.1 + 2.7764451052 * Math.sqrt(0.00025 / 5))) < 1e-12); + assert.equal(result.off.qualified, true); + assert.equal(result.on.qualified, true); + assert.throws(() => compare(cells.slice(1)), /thirty-two/); +}); + +for (const [name, mutate] of [ + ['mixed methods', rows => { delete rows[0].study_method; }], + ['unknown method', rows => { rows[0].study_method = 'invented'; }], + ['mixed candidate', rows => { rows[0].candidate = 'c'.repeat(40); }], + ['mixed baseline', rows => { rows[0].baseline = 'c'.repeat(40); }], + ['missing source', rows => { delete rows[0].candidate; }], + ['legacy Collector', rows => { delete rows[0].collector; }], + ['duplicate cell', rows => { rows[0] = {...rows[1]}; }], +]) { + test(`the prospective comparison refuses ${name}`, () => { + const rows = longPairData(); + mutate(rows); + assert.throws(() => compare(rows)); + }); +} + +test('the new limits do not relabel the historical comparison', () => { + const cells = pairData().map(cell => ({...cell, + cpu_seconds_per_workflow: cell.cell === 'off' ? 10.2 : cell.cell === 'on' ? 11 : 10})); + assert.equal(compare(cells).ordinary.off.qualified, false); + assert.equal(compare(cells).ordinary.on.qualified, false); +}); + +function cellData() { + const roles = [['app', 50000, 512], ...Array.from({length: 4}, () => ['worker', 100000, 384]), + ['mysql', 100000, 2048], ['redis', 10000, 128], ['collector', 20000, 256], ['probe', 20000, 256]]; + const rows = (seconds, usage) => roles.map(([role, quota, memory], index) => ({ + container: (index + 1).toString(16).padStart(64, '0'), role, + sampled_at: new Date(Date.UTC(2026, 0, 1, 0, 0, seconds)).toISOString(), + cpu: `usage_usec ${usage}\nthrottled_usec 0\n`, cpu_max: `${quota} 100000\n`, + memory_events: 'oom 0\noom_kill 0\n', memory_max: String(memory * 1024 * 1024), swap_max: '0', + memory_current_bytes: 1024, lifetime_memory_peak_bytes: 2048, + })); + const measure = {members: 1, offered_target: 120, offered: 120, accepted: 120, completed: 120, + observer_seconds: 90, missed_offers: 0, http_errors: 0, failures: [], queue_wake_enabled: true, + queue_driver: 'database', diagnostic_only: false, duration_seconds: 120, rate: 1, + elapsed_seconds: 120, p50_ms: 500, p95_ms: 750, p99_ms: 900}; + return {outcome: {result: 0, stage: 'complete', cell: 'base', workload: 'ordinary', repeat: '1'}, + measure, warmup: {...measure, offered_target: 30, offered: 30, accepted: 30, completed: 30}, + audit: {runs: 150, activities: 150, callbacks: 150, tasks: 450, unfinished_runs: 0, + unfinished_activities: 0, unfinished_tasks: 0, nonfirst_tasks: 0, unfinished_attempts: 0, + nonfirst_attempts: 0, queue: 0, failed_jobs: 0, repairs: 0, + history_types: {StartAccepted: 150, WorkflowStarted: 150, ActivityScheduled: 150, + ActivityStarted: 150, ActivityCompleted: 150, WorkflowCompleted: 150}, + metadata_bytes: {workflow_runs: 0, workflow_tasks: 0, activity_executions: 0}}, + telemetry: {ended: 0, received: 0}, workerExits: [0, 0, 0, 0], probeExits: [0, 0], + before: rows(0, 1000), after: rows(120, 101000), samples: [...rows(1, 2000), ...rows(2, 3000)], + exits: roles.map(([role], index) => ({id: (index + 1).toString(16).padStart(64, '0'), + exit: ['mysql', 'redis'].includes(role) ? 0 : 143, oom: false, restarts: 0}))}; +} + +test('normalizes all nine CPU deltas by measured work, excluding warmup counts', () => { + const result = summarizeCell(cellData()); + assert.ok(Math.abs(result.cpu_seconds - 0.9) < 1e-12); + assert.ok(Math.abs(result.cpu_seconds_per_workflow - 0.0075) < 1e-12); + assert.ok(Math.abs(result.php_cpu_seconds - 0.5) < 1e-12); + assert.equal(result.sample_interval_ms.mean, 1000); +}); + +function officialCollectorData() { + const data = cellData(); + data.outcome.collector = 'otelcol'; + data.outcome.collector_image = COLLECTOR_IMAGE; + const container = data.before.find(row => row.role === 'collector').container.slice(0, 12); + const initial = {schema: 'durable-workflow.collector-snapshot-v1', instance: 'collector-one', + container_prefix: container, + version: COLLECTOR_VERSION, uptime_seconds: 1, + accepted: 1, sent: 1, refused: 0, receiver_failed: 0, in_flight: 0, failed: null}; + data.telemetry.collector = {before: initial, after: {...initial, uptime_seconds: 2}}; + data.exits[7].exit = 0; + return data; +} + +function longCellData() { + const data = officialCollectorData(); + data.outcome.study_method = 'embedded-300s-5pairs-v2'; + Object.assign(data.measure, {offered_target: 300, offered: 300, accepted: 300, completed: 300, + duration_seconds: 300, elapsed_seconds: 300}); + Object.assign(data.audit, {runs: 330, activities: 330, callbacks: 330, tasks: 990}); + for (const key of Object.keys(data.audit.history_types)) data.audit.history_types[key] = 330; + for (const row of data.after) row.sampled_at = '2026-01-01T00:05:00.000Z'; + return data; +} + +test('the longer profile validates 300 measured runs and retains the original warmup and deadline', () => { + const result = summarizeCell(longCellData()); + assert.equal(result.measured.completed, 300); + assert.equal(result.audit_including_warmup.runs, 330); + assert.equal(result.completed_per_offer_second, 1); + assert.equal(result.measured.observer_seconds, 90); +}); + +test('the longer fan-out profile requires five measured 64-member workflows', () => { + const data = longCellData(); + data.outcome.workload = 'fanout'; + for (const [probe, count] of [[data.measure, 5], [data.warmup, 1]]) { + Object.assign(probe, {members: 64, rate: 1 / 60, offered_target: count, + offered: count, accepted: count, completed: count}); + } + Object.assign(data.audit, {runs: 6, activities: 384, callbacks: 384, tasks: 396}); + data.audit.history_types = {StartAccepted: 6, WorkflowStarted: 6, ActivityScheduled: 384, + ActivityStarted: 384, ActivityCompleted: 384, WorkflowCompleted: 6}; + assert.equal(summarizeCell(data).completed_per_offer_second, 1 / 60); + data.measure.members = 32; + assert.throws(() => summarizeCell(data)); +}); + +for (const [name, mutate] of [ + ['old window', data => { data.measure.duration_seconds = 120; }], + ['old counts', data => { data.measure.offered = 120; }], + ['extended observer', data => { data.measure.observer_seconds = 300; }], + ['missing pair marker', data => { delete data.outcome.study_method; }], + ['extra repetition', data => { data.outcome.repeat = '6'; }], + ['legacy fourth repetition', data => { delete data.outcome.study_method; data.outcome.repeat = '4'; }], +]) { + test(`the longer profile refuses ${name}`, () => { + const data = longCellData(); + mutate(data); + assert.throws(() => summarizeCell(data)); + }); +} + +test('an initialized official collector can prove zero new spans without inventing missing metrics', () => { + assert.equal(summarizeCell(officialCollectorData()).spans_including_warmup.received, 0); +}); + +test('official Collector graceful termination is exit zero', () => { + const data = officialCollectorData(); + data.exits[7].exit = 0; + assert.doesNotThrow(() => summarizeCell(data)); +}); + +test('official Collector forced termination is not graceful cleanup', () => { + const data = officialCollectorData(); + data.exits[7].exit = 143; + assert.throws(() => summarizeCell(data)); +}); + +for (const [name, corrupt] of [ + ['missing collector proof', data => { delete data.telemetry.collector; }], + ['collector replacement', data => { data.telemetry.collector.after.instance = 'collector-two'; }], + ['counter reset', data => { data.telemetry.collector.after.accepted = 0; }], + ['unreported accepted span', data => { + data.telemetry.collector.after.accepted = 2; + data.telemetry.collector.after.sent = 2; + }], + ['collector export loss', data => { data.telemetry.collector.after.accepted = 2; }], + ['new refusal observation', data => { data.telemetry.collector.after.refused = 1; }], + ['missing accepted counter', data => { delete data.telemetry.collector.after.accepted; }], + ['wrong image', data => { data.outcome.collector_image = 'unqualified:latest'; }], + ['new exporter failure', data => { data.telemetry.collector.after.failed = 1; }], + ['in-flight export', data => { data.telemetry.collector.after.in_flight = 1; }], + ['nonadvancing collector clock', data => { data.telemetry.collector.after.uptime_seconds = 1; }], + ['counter proof from another CPU role', data => { + data.telemetry.collector.before.container_prefix = 'ffffffffffff'; + data.telemetry.collector.after.container_prefix = 'ffffffffffff'; + }], +]) { + test(`official collector proof refuses ${name}`, () => { + const data = officialCollectorData(); + corrupt(data); + assert.throws(() => summarizeCell(data)); + }); +} + +function collectorText() { + const container = officialCollectorData().telemetry.collector.before.container_prefix; + const identity = `service_instance_id="collector-one",service_version="${COLLECTOR_VERSION}",qualification_container_id_prefix="${container}"`; + return [ + `otelcol_process_uptime{${identity}} 1`, + `otelcol_receiver_accepted_spans{receiver="otlp",${identity},transport="http"} 1`, + `otelcol_receiver_refused_spans{receiver="otlp",${identity},transport="http"} 0`, + `otelcol_receiver_failed_spans{receiver="otlp",${identity},transport="http"} 0`, + `otelcol_exporter_sent_spans{exporter="debug",${identity}} 1`, + `otelcol_exporter_in_flight_requests{data_type="traces",exporter="debug",${identity}} 0`, + ].join('\n'); +} + +test('reads initialized official metrics and preserves an unavailable failure series as null', () => { + const parsed = parseCollectorMetrics(collectorText()); + assert.deepEqual(parsed, officialCollectorData().telemetry.collector.before); + assert.equal(collectorDelta({before: parsed, after: {...parsed, uptime_seconds: 2, accepted: 4, sent: 4}}), 3); +}); + +test('Collector string-prefixed numeric container identity retains exact CPU-role binding', () => { + const identity = '5439113202e9'; + const previous = officialCollectorData().telemetry.collector.before.container_prefix; + const text = collectorText().replaceAll(previous, `container-${identity}`); + assert.equal(parseCollectorMetrics(text).container_prefix, identity); + assert.throws(() => parseCollectorMetrics(text.replaceAll(`container-${identity}`, 'container-invalid'))); +}); + +test('Collector numeric samples retain decimal and exponent forms', () => { + for (const value of ['1.', '+1', '+1.0e0', '1E0']) { + const text = collectorText().replaceAll('} 1', '} ' + value); + assert.equal(parseCollectorMetrics(text).accepted, 1); + } +}); + +for (const [name, corrupt] of [ + ['uninitialized series', text => text.split('\n')[0]], + ['missing refusal series', text => text.split('\n').filter(line => !line.includes('refused_spans')).join('\n')], + ['duplicate series', text => text + '\n' + text.split('\n')[1]], + ['fractional counter', text => text.replace('transport="http"} 1', 'transport="http"} 1.5')], + ['unsafe counter', text => text.replace('transport="http"} 1', 'transport="http"} 9007199254740992')], + ['nonfinite counter', text => text.replace('transport="http"} 1', 'transport="http"} NaN')], + ['mixed instances', text => text.replace('service_instance_id="', 'service_instance_id="other-')], + ['wrong version', text => text.replaceAll(COLLECTOR_VERSION, '0.0.0')], + ['wrong receiver', text => text.replaceAll('receiver="otlp"', 'receiver="other"')], + ['wrong transport', text => text.replaceAll('transport="http"', 'transport="grpc"')], + ['wrong exporter', text => text.replaceAll('exporter="debug"', 'exporter="other"')], + ['duplicate labels', text => text.replace('receiver="otlp"', 'receiver="otlp",receiver="other"')], + ['missing series instance', text => text.replace('service_instance_id="collector-one",', '')], + ['missing series version', text => text.replace(`service_version="${COLLECTOR_VERSION}",`, '')], + ['missing series CPU role', text => text.replace(/,qualification_container_id_prefix="[^"]+"/, '')], +]) { + test(`Collector metrics refuse ${name}`, () => { + assert.throws(() => parseCollectorMetrics(corrupt(collectorText()))); + }); +} + +test('Collector HTTP and byte failures do not become zero-span observations', async () => { + for (const response of [ + new Response('unavailable', {status: 503}), + new Response(null, {status: 200}), + new Response('x'.repeat(256 * 1024 + 1), {status: 200}), + new Response(new Uint8Array([255]), {status: 200}), + ]) { + await assert.rejects(readCollectorSnapshot(async () => response)); + } + const parsed = await readCollectorSnapshot(async (url, options) => { + assert.equal(url, 'http://collector:8888/metrics'); + assert.ok(options.signal instanceof AbortSignal); + return new Response(collectorText()); + }); + assert.equal(parsed.accepted, 1); +}); + +test('integer timer precision keeps the actual elapsed value without shortening CPU coverage', () => { + const data = cellData(); + data.measure.elapsed_seconds = 119.999219339; + assert.equal(summarizeCell(data).measured.elapsed_seconds, 119.999219339); +}); + +test('every cgroup interval must cover the declared offer window', () => { + const data = cellData(); + data.after[0].sampled_at = '2026-01-01T00:01:59.999Z'; + assert.throws(() => summarizeCell(data), /CPU interval/); +}); + +test('the observation cannot finish before the last declared offer', () => { + const data = cellData(); + data.measure.elapsed_seconds = 118.999; + assert.throws(() => summarizeCell(data)); +}); + +for (const [name, corrupt] of [ + ['failed original', data => { data.outcome.result = 1; }], + ['lost span', data => { data.outcome.cell = 'on'; data.telemetry = {ended: 10, received: 9}; }], + ['replaced container', data => { data.after[0].container = 'f'.repeat(64); }], + ['decreasing CPU', data => { data.after[0].cpu = 'usage_usec 999\nthrottled_usec 0\n'; }], + ['missing sample role', data => { data.samples.pop(); }], + ['unexpected worker exit', data => { data.workerExits[0] = 137; }], + ['unfinished business work', data => { data.audit.unfinished_tasks = 1; }], + ['resource-limit drift', data => { data.after[0].cpu_max = '100000 100000'; }], +]) { + test(`refuses ${name} as a qualified CPU sample`, () => { + const data = cellData(); + corrupt(data); + assert.throws(() => summarizeCell(data)); + }); +} + +test('uses paired relative overhead and the frozen confidence targets', () => { + const result = compare(pairData()); + for (const workload of ['ordinary', 'fanout']) { + assert.ok(Math.abs(result[workload].off.confidence_95.mean - 0.002) < 1e-12); + assert.ok(Math.abs(result[workload].on.confidence_95.mean - 0.02) < 1e-12); + assert.equal(result[workload].off.qualified, true); + assert.equal(result[workload].on.qualified, true); + } +}); + +test('collector profiles cannot be mixed within a matched CPU comparison', () => { + const cells = pairData(); + cells[0].collector = 'otelcol'; + cells[0].collector_image = COLLECTOR_IMAGE; + assert.throws(() => compare(cells), /Collector profiles/); +}); + +test('a low mean with a wide interval does not qualify enabled tracing', () => { + const cells = pairData(); + for (const [index, repeat] of ['1', '2', '3'].entries()) { + cells.find(row => row.workload === 'ordinary' && row.cell === 'on' && row.repeat === repeat) + .cpu_seconds_per_workflow = [10, 10.3, 9.7][index]; + } + const result = compare(cells).ordinary.on; + assert.ok(Math.abs(result.confidence_95.mean) < 1e-12); + assert.ok(result.confidence_95.upper > 0.05); + assert.equal(result.qualified, false); +}); + +test('baseline drift can refuse the tighter target while the enabled target passes', () => { + const cells = pairData(); + cells.find(row => row.workload === 'ordinary' && row.repeat === 'drift').cpu_seconds_per_workflow = 10.15; + const result = compare(cells).ordinary; + assert.equal(result.off.qualified, false); + assert.equal(result.on.qualified, true); +}); + +test('missing or duplicate cells cannot be silently selected out', () => { + assert.throws(() => compare(pairData().slice(1)), /twenty/); + const cells = pairData(); + cells[0] = {...cells[1]}; + assert.throws(() => compare(cells), /Missing or repeated/); +}); + +test('canonical input selection refuses parent, sibling-prefix and linked escapes', async t => { + const workspace = await mkdtemp(path.join(process.cwd(), '.analysis-input-')); + const sibling = workspace + '-sibling'; + await mkdir(sibling); + t.after(async () => { + await rm(workspace, {recursive: true}); + await rm(sibling, {recursive: true}); + }); + assert.equal(await experimentRoot('.', workspace), workspace); + await assert.rejects(experimentRoot('..', workspace), /escapes/); + await assert.rejects(experimentRoot(sibling, workspace), /escapes/); + await symlink(sibling, path.join(workspace, 'linked-root')); + await assert.rejects(experimentRoot('linked-root', workspace), /escapes/); +}); + +test('a copied result cannot link a data file outside its selected experiment', async t => { + const workspace = await mkdtemp(path.join(process.cwd(), '.analysis-data-')); + t.after(async () => { await rm(workspace, {recursive: true}); }); + const root = path.join(workspace, 'experiment'); + await mkdir(root); + const normal = path.join(root, 'result.json'); + const outside = path.join(workspace, 'outside.json'); + await writeFile(normal, '{}'); + await writeFile(outside, '{}'); + assert.equal(await dataPath(root, normal), normal); + const link = path.join(root, 'linked.json'); + await symlink(outside, link); + await assert.rejects(dataPath(root, link), /escapes/); +}); diff --git a/.github/tracing-performance/app.php b/.github/tracing-performance/app.php new file mode 100644 index 000000000..adbb08ec4 --- /dev/null +++ b/.github/tracing-performance/app.php @@ -0,0 +1,203 @@ + ['providers' => [WorkflowServiceProvider::class], 'dont-discover' => ['*']], +]); +$mode = PHP_SAPI === 'cli-server' ? 'http' : ($argv[1] ?? ''); +if ($mode === 'prepare') { + if (Schema::hasTable('migrations')) { + throw new LogicException('Prepare requires a fresh disposable database.'); + } + if (Artisan::call('migrate', ['--force' => true]) !== 0) { + throw new UnexpectedValueException(Artisan::output()); + } + if (config('queue.default') === 'database') { + Schema::create('jobs', static function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + Schema::create('failed_jobs', static function (Blueprint $table): void { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + Schema::create('benchmark_callbacks', static function (Blueprint $table): void { + $table->string('activity_id')->primary(); + $table->string('run_id'); + $table->unsignedInteger('member'); + $table->unique(['run_id', 'member']); + }); + echo json_encode(['prepared' => true, 'framework' => $app->version()], JSON_THROW_ON_ERROR), PHP_EOL; + exit(0); +} +if ($mode === 'audit') { + $tables = ['workflow_runs', 'workflow_tasks', 'activity_executions']; + $metadata = []; + foreach ($tables as $table) { + $metadata[$table] = Schema::hasColumn($table, 'observability_context') + ? (int) DB::table($table)->selectRaw('COALESCE(SUM(OCTET_LENGTH(observability_context)), 0) AS bytes') + ->value('bytes') : 0; + } + $result = [ + 'runs' => WorkflowRun::query()->count(), + 'unfinished_runs' => WorkflowRun::query()->where('status', '!=', 'completed')->count(), + 'activities' => ActivityExecution::query()->count(), + 'unfinished_activities' => ActivityExecution::query()->where('status', '!=', 'completed')->count(), + 'tasks' => WorkflowTask::query()->count(), + 'unfinished_tasks' => WorkflowTask::query()->where('status', '!=', 'completed')->count(), + 'nonfirst_tasks' => WorkflowTask::query()->where('attempt_count', '!=', 1)->count(), + 'repairs' => (int) WorkflowTask::query()->sum('repair_count'), + 'unfinished_attempts' => ActivityAttempt::query()->where('status', '!=', 'completed')->count(), + 'nonfirst_attempts' => ActivityAttempt::query()->where('attempt_number', '!=', 1)->count(), + 'callbacks' => DB::table('benchmark_callbacks')->count(), + 'queue' => $app['queue']->connection()->size(), + 'failed_jobs' => DB::table('failed_jobs')->count(), + 'history_types' => WorkflowHistoryEvent::query()->selectRaw('event_type, COUNT(*) AS count') + ->groupBy('event_type')->pluck('count', 'event_type')->all(), + 'metadata_bytes' => $metadata, + ]; + echo json_encode($result, JSON_THROW_ON_ERROR), PHP_EOL; + exit((array_sum(array_intersect_key($result, array_flip([ + 'unfinished_runs', 'unfinished_activities', 'unfinished_tasks', 'nonfirst_tasks', + 'unfinished_attempts', + 'nonfirst_attempts', 'queue', 'failed_jobs', + ]))) === 0 && $result['callbacks'] === $result['activities']) ? 0 : 1); +} +if (! in_array($mode, ['work', 'http'], true)) { + throw new InvalidArgumentException('Expected prepare, work, audit or the HTTP router.'); +} +$http = new HttpFactory(); +$exportScope = Configurator::createNoop()->storeInContext(Context::getRoot())->activate(); +try { + $transport = (new PsrTransportFactory( + new Client(['timeout' => 0.1, 'connect_timeout' => 0.1]), $http, $http, + ))->create(getenv('BENCH_COLLECTOR') ?: 'http://collector:4318/v1/traces', ContentTypes::PROTOBUF, maxRetries: 0); + $processor = new CountingProcessor(new BatchSpanProcessor( + new SpanExporter($transport), Clock::getDefault(), + maxQueueSize: 256, maxExportBatchSize: 64, autoFlush: false, + )); +} finally { + $exportScope->detach(); +} +$provider = new TracerProvider([$processor]); +$configuration = Configurator::create()->withTracerProvider($provider) + ->withPropagator(TraceContextPropagator::getInstance())->activate(); +$exitCode = 0; +try { + if ($mode === 'work') { + $nextFlush = hrtime(true) + 1_000_000_000; + $app['events']->listen(\Illuminate\Queue\Events\Looping::class, static function () use ( + $app, + $provider, + &$nextFlush + ): void { + if (is_file(getenv('BENCH_STATE') . '/stop-workers')) { + $app['queue.worker']->shouldQuit = true; + } + if (hrtime(true) >= $nextFlush) { + $provider->forceFlush(); + $nextFlush = hrtime(true) + 1_000_000_000; + } + }); + $exitCode = Artisan::call('queue:work', [ + 'connection' => config('queue.default'), '--queue' => 'default', '--sleep' => 1, + '--tries' => 1, '--timeout' => 125, '--max-time' => 900, '--memory' => 256, + ]); + } else { + respond(); + } +} finally { + $configuration->detach(); + $provider->shutdown(); +} +exit($exitCode); + +function respond(): void +{ + header('Content-Type: application/json'); + $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + if ($_SERVER['REQUEST_METHOD'] === 'GET' && $path === '/health') { + echo json_encode([ + 'ready' => true, + 'queue_wake_enabled' => (bool) config('workflows.v2.matching_role.queue_wake_enabled'), + 'queue_driver' => config('queue.default'), + ], JSON_THROW_ON_ERROR); + return; + } + $id = $path === '/workflows' ? null : substr($path, strlen('/workflows/')); + if ($_SERVER['REQUEST_METHOD'] === 'POST' && $path === '/workflows') { + $input = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR); + if (! is_string($input['id'] ?? null) || ! preg_match('/^[a-z0-9-]{1,80}$/D', $input['id']) + || ! in_array($input['members'] ?? null, [1, 64], true)) { + throw new InvalidArgumentException('Expected a synthetic id and 1 or 64 members.'); + } + $scope = TraceContextPropagator::getInstance()->extract(array_change_key_case(getallheaders()))->activate(); + try { + WorkflowStub::make(BenchmarkWorkflow::class, $input['id'], 'default')->start( + new StartOptions(executionTimeoutSeconds: 120, runTimeoutSeconds: 100), $input['members'], + ); + } finally { + $scope->detach(); + } + $id = $input['id']; + } elseif ($_SERVER['REQUEST_METHOD'] !== 'GET' || ! str_starts_with($path, '/workflows/')) { + http_response_code(404); + echo '{}'; + return; + } + $run = WorkflowRun::query()->where('workflow_instance_id', $id)->where('namespace', 'default')->sole(); + echo json_encode([ + 'id' => $id, 'run_id' => $run->id, 'status' => $run->status->value, + 'execution_deadline' => $run->getRawOriginal('execution_deadline_at'), + 'run_deadline' => $run->getRawOriginal('run_deadline_at'), 'output' => $run->workflowOutput(), + ], JSON_THROW_ON_ERROR); +} diff --git a/.github/tracing-performance/collector-metrics.mjs b/.github/tracing-performance/collector-metrics.mjs new file mode 100644 index 000000000..bdf19520b --- /dev/null +++ b/.github/tracing-performance/collector-metrics.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import {pathToFileURL} from 'node:url'; + +export const COLLECTOR_VERSION = '0.163.1'; +export const COLLECTOR_IMAGE = 'otel/opentelemetry-collector:0.163.1@sha256:f0e23036dce3db3f9cb80d907c75a0d034cd32e93511a1943482d46129b9ae9c'; +const maximumBytes = 256 * 1024; +const metricFields = new Map([ + ['otelcol_process_uptime', 'uptime_seconds'], + ['otelcol_receiver_accepted_spans', 'accepted'], + ['otelcol_receiver_refused_spans', 'refused'], + ['otelcol_receiver_failed_spans', 'receiver_failed'], + ['otelcol_exporter_sent_spans', 'sent'], + ['otelcol_exporter_send_failed_spans', 'failed'], + ['otelcol_exporter_in_flight_requests', 'in_flight'], +]); + +function labels(value) { + const result = Object.create(null); + const pattern = /([a-zA-Z_]\w*)=("(?:[^"\\]|\\["\\n])*")(?:,|$)/gy; + let offset = 0; + for (const match of value.matchAll(pattern)) { + assert.equal(match.index, offset, 'Malformed metric labels'); + assert.ok(!Object.hasOwn(result, match[1]), 'Duplicate metric label'); + result[match[1]] = JSON.parse(match[2]); + offset = match.index + match[0].length; + } + assert.equal(offset, value.length, 'Incomplete metric labels'); + return result; +} + +function metric(line) { + const match = /^(otelcol_[a-z_]+)\{(.*)\} (\S+)$/.exec(line); + assert.ok(match, 'Malformed Collector counter sample'); + assert.match(match[3], /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/, + 'Malformed Collector counter number'); + const attributes = labels(match[2]); + if (match[1].startsWith('otelcol_receiver_')) { + assert.equal(attributes.receiver, 'otlp', 'Unexpected receiver'); + assert.equal(attributes.transport, 'http', 'Unexpected receiver transport'); + } + if (match[1].startsWith('otelcol_exporter_')) { + assert.equal(attributes.exporter, 'debug', 'Unexpected exporter'); + if (Object.hasOwn(attributes, 'data_type')) assert.equal(attributes.data_type, 'traces'); + } + return {field: metricFields.get(match[1]), attributes, value: Number(match[3])}; +} + +function validateSnapshot(snapshot) { + assert.equal(snapshot.schema, 'durable-workflow.collector-snapshot-v1'); + assert.equal(snapshot.version, COLLECTOR_VERSION, 'Unexpected Collector version'); + assert.match(snapshot.instance, /^[a-zA-Z0-9._-]{1,128}$/, 'Missing Collector instance'); + assert.match(snapshot.container_prefix, /^[a-f0-9]{12}$/, 'Missing Collector container identity'); + assert.ok(Number.isFinite(snapshot.uptime_seconds) && snapshot.uptime_seconds > 0, 'Invalid Collector uptime'); + for (const field of ['accepted', 'sent', 'refused', 'receiver_failed', 'in_flight']) { + assert.ok(Number.isSafeInteger(snapshot[field]) && snapshot[field] >= 0, `Missing/invalid ${field} counter`); + } + assert.ok(snapshot.accepted >= 1, 'Initialize the counter series with the declared synthetic probe'); + assert.equal(snapshot.accepted, snapshot.sent, 'Collector export has not drained'); + assert.equal(snapshot.in_flight, 0, 'Collector export is still in flight'); + assert.ok(snapshot.failed === null || (Number.isSafeInteger(snapshot.failed) && snapshot.failed >= 0), + 'Invalid exporter failure counter'); +} + +export function parseCollectorMetrics(text) { + assert.equal(typeof text, 'string'); + assert.ok(Buffer.byteLength(text) <= maximumBytes, 'Collector metrics exceed the bounded response size'); + const snapshot = {schema: 'durable-workflow.collector-snapshot-v1', failed: null}; + const seen = new Set(); + for (const line of text.split('\n')) { + const name = line.split(/[ {]/, 1)[0]; + if (!metricFields.has(name)) continue; + const sample = metric(line); + assert.ok(!seen.has(sample.field), 'Duplicate Collector counter series'); + seen.add(sample.field); + const {service_instance_id: instance, service_version: version} = sample.attributes; + const containerLabel = sample.attributes.qualification_container_id_prefix; + const container = containerLabel?.startsWith('container-') ? containerLabel.slice(10) : containerLabel; + assert.match(instance, /^[a-zA-Z0-9._-]{1,128}$/, 'Missing series instance'); + assert.equal(version, COLLECTOR_VERSION, 'Missing/wrong series version'); + assert.match(container, /^[a-f0-9]{12}$/, 'Missing series container identity'); + snapshot.instance ??= instance; + snapshot.version ??= version; + snapshot.container_prefix ??= container; + assert.equal(instance, snapshot.instance, 'Mixed Collector instances'); + assert.equal(version, snapshot.version, 'Mixed Collector versions'); + assert.equal(container, snapshot.container_prefix, 'Mixed Collector containers'); + snapshot[sample.field] = sample.value; + } + validateSnapshot(snapshot); + return snapshot; +} + +export function collectorDelta(proof) { + assert.ok(proof?.before && proof?.after, 'Missing Collector proof'); + const {before, after} = proof; + validateSnapshot(before); + validateSnapshot(after); + assert.equal(before.instance, after.instance, 'Collector instance changed'); + assert.equal(before.container_prefix, after.container_prefix, 'Collector container changed'); + assert.ok(after.uptime_seconds > before.uptime_seconds, 'Collector uptime did not advance'); + assert.ok(after.accepted >= before.accepted && after.sent >= before.sent, 'Collector counter reset'); + for (const field of ['refused', 'receiver_failed', 'failed']) { + assert.equal(after[field], before[field], `Collector ${field} observation changed`); + } + return after.accepted - before.accepted; +} + +export async function readCollectorSnapshot(fetcher = fetch) { + const response = await fetcher('http://collector:8888/metrics', {signal: AbortSignal.timeout(2000)}); + assert.equal(response.status, 200, 'Collector metrics HTTP refusal'); + assert.ok(response.body, 'Missing Collector metrics body'); + const chunks = []; + let bytes = 0; + for await (const chunk of response.body) { + bytes += chunk.byteLength; + assert.ok(bytes <= maximumBytes, 'Collector metrics exceed the bounded response size'); + chunks.push(chunk); + } + return parseCollectorMetrics(new TextDecoder('utf-8', {fatal: true}).decode(Buffer.concat(chunks))); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + assert.equal(process.argv.length, 2, 'This command reads the fixed isolated Collector endpoint'); + console.log(JSON.stringify(await readCollectorSnapshot())); +} diff --git a/.github/tracing-performance/collector-otelcol.yaml b/.github/tracing-performance/collector-otelcol.yaml new file mode 100644 index 000000000..d6ba23f49 --- /dev/null +++ b/.github/tracing-performance/collector-otelcol.yaml @@ -0,0 +1,32 @@ +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + max_request_body_size: 1048576 +exporters: + debug: + verbosity: basic + sampling_initial: 1 + sampling_thereafter: 100000 +service: + telemetry: + resource: + # A hexadecimal container ID may look numeric to the environment provider. + qualification.container_id_prefix: container-${env:HOSTNAME} + logs: + level: error + metrics: + readers: + - pull: + exporter: + prometheus: + host: 0.0.0.0 + port: 8888 + without_type_suffix: true + with_resource_constant_labels: + included: [service.instance.id, service.version, qualification.container_id_prefix] + pipelines: + traces: + receivers: [otlp] + exporters: [debug] diff --git a/.github/tracing-performance/collector-probe.php b/.github/tracing-performance/collector-probe.php new file mode 100644 index 000000000..26f30807a --- /dev/null +++ b/.github/tracing-performance/collector-probe.php @@ -0,0 +1,38 @@ + ['min_range' => 1, 'max_range' => 64], +]); +if ($count === false) { + throw new InvalidArgumentException('Supply one to sixty-four synthetic calibration spans.'); +} +$http = new HttpFactory(); +$transport = (new PsrTransportFactory( + new Client(['timeout' => 1, 'connect_timeout' => 1]), $http, $http, +))->create('http://collector:4318/v1/traces', ContentTypes::PROTOBUF, maxRetries: 0); +$processor = new BatchSpanProcessor( + new SpanExporter($transport), Clock::getDefault(), + maxQueueSize: 256, maxExportBatchSize: 64, autoFlush: false, +); +$provider = new TracerProvider([$processor]); +$tracer = $provider->getTracer('durable-workflow.collector-calibration', '1'); +for ($index = 0; $index < $count; ++$index) { + $tracer->spanBuilder('qualification.collector.seed')->startSpan()->end(); +} +if (! $provider->shutdown()) { + throw new UnexpectedValueException('Collector calibration did not drain successfully.'); +} +echo json_encode(['synthetic_spans' => $count, 'provider_shutdown' => true], JSON_THROW_ON_ERROR), PHP_EOL; diff --git a/.github/tracing-performance/collector.php b/.github/tracing-performance/collector.php new file mode 100644 index 000000000..cde5b3bd6 --- /dev/null +++ b/.github/tracing-performance/collector.php @@ -0,0 +1,18 @@ +mergeFromString(file_get_contents('php://input')); +$count = 0; +foreach ($request->getResourceSpans() as $resource) { + foreach ($resource->getScopeSpans() as $scope) { + $count += count($scope->getSpans()); + } +} +file_put_contents(getenv('BENCH_STATE') . '/received.jsonl', json_encode([ + 'spans' => $count, 'bytes' => strlen($request->serializeToString()), +], JSON_THROW_ON_ERROR) . PHP_EOL, FILE_APPEND | LOCK_EX); +header('Content-Type: application/x-protobuf'); +echo (new Opentelemetry\Proto\Collector\Trace\V1\ExportTraceServiceResponse())->serializeToString(); diff --git a/.github/tracing-performance/compose.collector.yaml b/.github/tracing-performance/compose.collector.yaml new file mode 100644 index 000000000..e8037039c --- /dev/null +++ b/.github/tracing-performance/compose.collector.yaml @@ -0,0 +1,5 @@ +# Prospective official-Collector profile. The October 17 PHP receiver is unchanged. +services: + collector: + image: otel/opentelemetry-collector:0.163.1@sha256:f0e23036dce3db3f9cb80d907c75a0d034cd32e93511a1943482d46129b9ae9c + command: ["/otelcol", "--config=/app/collector-otelcol.yaml"] diff --git a/.github/tracing-performance/compose.measure.yaml b/.github/tracing-performance/compose.measure.yaml new file mode 100644 index 000000000..8304b0fc2 --- /dev/null +++ b/.github/tracing-performance/compose.measure.yaml @@ -0,0 +1,13 @@ +services: + worker: + environment: + BENCH_ROLE: worker + command: ["sh", "retain-process.sh", "php", "app.php", "work"] + probe: + init: true + stop_signal: SIGTERM + environment: + BENCH_ROLE: probe + BENCH_STATE: /results + BENCH_RELEASE_FILE: /results/release + command: ["sh", "retain-process.sh", "node", "probe.mjs"] diff --git a/.github/tracing-performance/compose.yaml b/.github/tracing-performance/compose.yaml new file mode 100644 index 000000000..7f7404671 --- /dev/null +++ b/.github/tracing-performance/compose.yaml @@ -0,0 +1,121 @@ +x-runtime: &runtime + image: ${BENCH_PHP_IMAGE:?Set the qualified PHP image} + user: "1000:1000" + entrypoint: [] + init: true + stop_signal: SIGTERM + healthcheck: {disable: true} + working_dir: /app + volumes: + - ${BENCH_APP_DIR:-.}:/app:ro + - ${BENCH_STATE_DIR:-./state}/runtime:/state + environment: + DB_HOST: mysql + DB_PORT: "3306" + DB_DATABASE: benchmark + DB_USERNAME: benchmark + DB_PASSWORD: disposable-benchmark + REDIS_HOST: redis + BENCH_STATE: /state + BENCH_TRACING: ${BENCH_TRACING:-false} + BENCH_QUEUE_WAKE: ${BENCH_QUEUE_WAKE:-true} + BENCH_QUEUE_DRIVER: ${BENCH_QUEUE_DRIVER:-database} + BENCH_COLLECTOR: http://collector:4318/v1/traces + depends_on: + mysql: {condition: service_healthy} + redis: {condition: service_healthy} + +services: + mysql: + image: ${BENCH_MYSQL_IMAGE:-mysql:8.4} + cpus: 1 + mem_limit: 2g + memswap_limit: 2g + command: ["--innodb-buffer-pool-size=536870912", "--skip-log-bin"] + environment: + MYSQL_ROOT_PASSWORD: disposable-root-benchmark + MYSQL_DATABASE: benchmark + MYSQL_USER: benchmark + MYSQL_PASSWORD: disposable-benchmark + volumes: ["database:/var/lib/mysql"] + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_PASSWORD mysql --user=$$MYSQL_USER --database=$$MYSQL_DATABASE --host=127.0.0.1 -Nse 'SELECT 1'"] + interval: 2s + timeout: 2s + retries: 60 + redis: + image: ${BENCH_REDIS_IMAGE:-redis:7.4} + user: "1000:1000" + cpus: 0.1 + mem_limit: 128m + memswap_limit: 128m + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 2s + retries: 20 + prepare: + <<: *runtime + profiles: ["tools"] + cpus: 0.5 + mem_limit: 512m + memswap_limit: 512m + command: ["php", "app.php", "prepare"] + app: + <<: *runtime + cpus: 0.5 + mem_limit: 512m + memswap_limit: 512m + command: ["php", "-S", "0.0.0.0:8080", "app.php"] + worker: + <<: *runtime + cpus: 1 + mem_limit: 384m + memswap_limit: 384m + stop_grace_period: 10s + command: ["php", "app.php", "work"] + collector: + image: ${BENCH_PHP_IMAGE:?Set the qualified PHP image} + user: "1000:1000" + entrypoint: [] + init: true + stop_signal: SIGTERM + healthcheck: {disable: true} + working_dir: /app + cpus: 0.2 + mem_limit: 256m + memswap_limit: 256m + volumes: + - ${BENCH_APP_DIR:-.}:/app:ro + - ${BENCH_STATE_DIR:-./state}/collector:/state + environment: {BENCH_STATE: /state} + command: ["php", "-S", "0.0.0.0:4318", "collector.php"] + probe: + image: ${BENCH_NODE_IMAGE:-node:22-alpine} + user: "1000:1000" + working_dir: /app + profiles: ["tools"] + cpus: 0.2 + mem_limit: 256m + memswap_limit: 256m + volumes: + - ${BENCH_APP_DIR:-.}:/app:ro + - ${BENCH_STATE_DIR:-./state}/results:/results + environment: + BASE_URL: http://app:8080 + PROFILE_NAME: ${PROFILE_NAME:?Set a unique synthetic profile} + WORKLOAD_NAME: ${WORKLOAD_NAME:-} + MEMBERS: ${MEMBERS:-1} + OFFERED_RATE: ${OFFERED_RATE:-} + DURATION_SECONDS: ${DURATION_SECONDS:-} + DIAGNOSTIC_ONLY: ${DIAGNOSTIC_ONLY:-false} + BENCH_QUEUE_DRIVER: ${BENCH_QUEUE_DRIVER:-database} + command: ["node", "probe.mjs"] + +volumes: + database: + +networks: + default: + internal: true diff --git a/.github/tracing-performance/composer.json b/.github/tracing-performance/composer.json new file mode 100644 index 000000000..81e18c10f --- /dev/null +++ b/.github/tracing-performance/composer.json @@ -0,0 +1,21 @@ +{ + "name": "durable-workflow/tracing-performance", + "description": "Disposable embedded tracing overhead workload", + "type": "project", + "license": "MIT", + "require": { + "php": "^8.3", + "ext-pdo_mysql": "*", + "ext-redis": "*", + "durable-workflow/workflow": "2.7.3", + "guzzlehttp/guzzle": "8.2.0", + "laravel/framework": "13.37.0", + "open-telemetry/api": "1.10.1", + "open-telemetry/context": "1.5.0", + "open-telemetry/exporter-otlp": "^1.4", + "open-telemetry/sdk": "1.15.0", + "orchestra/testbench": "11.3.0" + }, + "autoload": {"psr-4": {"TracingBenchmark\\": "src/"}}, + "config": {"allow-plugins": false, "sort-packages": true} +} diff --git a/.github/tracing-performance/composer.lock b/.github/tracing-performance/composer.lock new file mode 100644 index 000000000..aee34f7dd --- /dev/null +++ b/.github/tracing-performance/composer.lock @@ -0,0 +1,9704 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "29a6a55a2de8476ffa9fff63976f6432", + "packages": [ + { + "name": "apache/avro", + "version": "1.12.2", + "source": { + "type": "git", + "url": "https://github.com/apache/avro.git", + "reference": "8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/apache/avro/zipball/8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772", + "reference": "8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-json": "*", + "ext-pcntl": "*", + "ext-xml": "*", + "php-mock/php-mock-phpunit": "^2.10", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "squizlabs/php_codesniffer": "3.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Apache\\Avro\\": "lang/php/lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Apache Avro Developers", + "email": "dev@avro.apache.org", + "homepage": "http://avro.apache.org" + } + ], + "description": "Apache Avro™ is a data serialization system.", + "homepage": "http://avro.apache.org", + "keywords": [ + "avro", + "data", + "serialization" + ], + "support": { + "email": "dev@avro.apache.org", + "issues": "https://issues.apache.org/jira/browse/AVRO", + "source": "https://github.com/apache/avro/tree/release-1.12.2" + }, + "time": "2026-08-07T12:56:37+00:00" + }, + { + "name": "brick/math", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-09-12T10:28:18+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2026-09-06T14:11:38+00:00" + }, + { + "name": "composer/semver", + "version": "3.5.1", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.5.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-10-05T13:12:12+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.3" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2026-10-01T06:59:29+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "durable-workflow/workflow", + "version": "2.7.3", + "source": { + "type": "git", + "url": "https://github.com/durable-workflow/workflow.git", + "reference": "dda951d93f58b82447348f1b3b4e4e17dba149fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/dda951d93f58b82447348f1b3b4e4e17dba149fb", + "reference": "dda951d93f58b82447348f1b3b4e4e17dba149fb", + "shasum": "" + }, + "require": { + "apache/avro": "^1.12", + "laravel/framework": "^9.52.17|^10.48.29|^11.44.1|^12.69.0|^13.30.0", + "php": "^8.1", + "react/promise": "^2.9|^3.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "replace": { + "laravel-workflow/laravel-workflow": "self.version" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/dbal": "^3.7", + "orchestra/testbench": "^8.0|^11.0", + "phpstan/phpstan": "^2.0", + "symplify/easy-coding-standard": "^11.0" + }, + "suggest": { + "durable-workflow/sdk": "Framework-neutral client and remote-worker SDK for the standalone Durable Workflow server." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Workflow\\Providers\\WorkflowServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "2.0.x-dev" + }, + "durable-workflow": { + "product-train": "2.7.3", + "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", + "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" + } + }, + "autoload": { + "files": [ + "src/functions.php", + "src/V2/functions.php" + ], + "psr-4": { + "Workflow\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Durable Workflow", + "email": "support@durable-workflow.com" + } + ], + "description": "Embedded durable workflow runtime and orchestration engine for Laravel applications.", + "support": { + "issues": "https://github.com/durable-workflow/workflow/issues", + "source": "https://github.com/durable-workflow/workflow/tree/2.7.3" + }, + "time": "2026-10-20T13:20:34+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.6", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "7d0a2cde892089bb09372f4366e9b960eb3001a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/7d0a2cde892089bb09372f4366e9b960eb3001a9", + "reference": "7d0a2cde892089bb09372f4366e9b960eb3001a9", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58", + "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.6" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2026-10-03T17:03:57+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "google/protobuf", + "version": "v5.36.2", + "source": { + "type": "git", + "url": "https://github.com/protocolbuffers/protobuf-php.git", + "reference": "020bc8e831611869ac387ddd38fbbadd78d15195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/020bc8e831611869ac387ddd38fbbadd78d15195", + "reference": "020bc8e831611869ac387ddd38fbbadd78d15195", + "shasum": "" + }, + "require": { + "php": ">=8.2.0" + }, + "require-dev": { + "phpunit/phpunit": ">=11.5.50 <12.0.0" + }, + "suggest": { + "ext-bcmath": "Need to support JSON deserialization" + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Protobuf\\": "src/Google/Protobuf", + "GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "proto library for PHP", + "homepage": "https://developers.google.com/protocol-buffers/", + "keywords": [ + "proto" + ], + "support": { + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.2" + }, + "time": "2026-09-17T15:56:42+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "8.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^3.0.2", + "guzzlehttp/psr7": "^3.1", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "4.0.1", + "guzzlehttp/test-server": "^1.0.1", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/8.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-09-06T13:55:09+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T10:00:26+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T11:02:13+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7a466ad606491eb6528c717482f7cca77f1851f3", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34", + "uri-template/tests": "1.0.2" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-08-24T17:13:02+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "laravel/framework", + "version": "v13.37.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "685f8d6c2ab4547287500bc80ccdf2ca12635109" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/685f8d6c2ab4547287500bc80ccdf2ca12635109", + "reference": "685f8d6c2ab4547287500bc80ccdf2ca12635109", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", + "laravel/prompts": "^0.3.25", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.10", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/image": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "intervention/image": "^4.2", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^11.6.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "2.3.1", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "2.6.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/mercure": "^0.8.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0", + "web-token/jwt-library": "^4.1" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "intervention/image": "Required to use the image processing features (^4.2).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).", + "web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-10-16T04:59:27+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.26", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0", + "reference": "2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "source": "https://github.com/laravel/prompts/tree/v0.3.26" + }, + "time": "2026-10-09T14:45:13+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-09-22T14:32:34+00:00" + }, + { + "name": "laravel/tinker", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", + "shasum": "" + }, + "require": { + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" + }, + "time": "2026-03-17T14:54:13+00:00" + }, + { + "name": "league/commonmark", + "version": "2.10.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f", + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.11-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-09-21T13:07:34+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" + }, + "time": "2026-09-02T08:00:27+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" + }, + "time": "2026-08-12T13:29:21+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "fcc872a3d872aa13f935e0cd1887b9abb06da07f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/fcc872a3d872aa13f935e0cd1887b9abb06da07f", + "reference": "fcc872a3d872aa13f935e0cd1887b9abb06da07f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/uri-interfaces": "^7.9.0", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.9.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-10-16T15:19:33+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58", + "reference": "eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.9.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-10-16T15:19:33+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.15", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0 || ^3.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2026-08-19T19:37:52+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.12.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.12.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-29T19:21:12+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.14.2", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "94cfc34887f07ab5c0efa07914c8f527544e3224" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/94cfc34887f07ab5c0efa07914c8f527544e3224", + "reference": "94cfc34887f07ab5c0efa07914c8f527544e3224", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^3.0@dev", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-10-02T07:26:52+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.6", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "c54350438cd6914616f790a49cb424605f421562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.6" + }, + "time": "2026-08-16T21:58:41+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.5", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-07-15T19:09:14+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "nyholm/psr7-server", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7-server.git", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "nyholm/nsa": "^1.1", + "nyholm/psr7": "^1.3", + "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Nyholm\\Psr7Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "Helper classes to handle PSR-7 server requests", + "homepage": "http://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7-server/issues", + "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2023-11-08T09:30:43+00:00" + }, + { + "name": "open-telemetry/api", + "version": "1.10.1", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/api.git", + "reference": "b2bc26bf8bd6b2b6447b3626b2a1b5733873aa0b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/b2bc26bf8bd6b2b6447b3626b2a1b5733873aa0b", + "reference": "b2bc26bf8bd6b2b6447b3626b2a1b5733873aa0b", + "shasum": "" + }, + "require": { + "open-telemetry/context": "^1.4", + "php": "^8.1", + "psr/log": "^1.1|^2.0|^3.0", + "symfony/polyfill-php82": "^1.26" + }, + "conflict": { + "open-telemetry/sdk": "<=1.11" + }, + "type": "library", + "extra": { + "spi": { + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" + ] + }, + "branch-alias": { + "dev-main": "1.8.x-dev" + } + }, + "autoload": { + "files": [ + "Trace/functions.php" + ], + "psr-4": { + "OpenTelemetry\\API\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "API for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "api", + "apm", + "logging", + "opentelemetry", + "otel", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-09-16T01:59:09+00:00" + }, + { + "name": "open-telemetry/context", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/context.git", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/polyfill-php82": "^1.26" + }, + "suggest": { + "ext-ffi": "To allow context switching in Fibers" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "files": [ + "fiber/initialize_fiber_handler.php" + ], + "psr-4": { + "OpenTelemetry\\Context\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "Context implementation for OpenTelemetry PHP.", + "keywords": [ + "Context", + "opentelemetry", + "otel" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2025-10-19T06:44:33+00:00" + }, + { + "name": "open-telemetry/exporter-otlp", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/exporter-otlp.git", + "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/283a0d66522f2adc6d8d7debfd7686be91c282be", + "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be", + "shasum": "" + }, + "require": { + "open-telemetry/api": "^1.0", + "open-telemetry/gen-otlp-protobuf": "^1.1", + "open-telemetry/sdk": "^1.0", + "php": "^8.1", + "php-http/discovery": "^1.14" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "files": [ + "_register.php" + ], + "psr-4": { + "OpenTelemetry\\Contrib\\Otlp\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "OTLP exporter for OpenTelemetry.", + "keywords": [ + "Metrics", + "exporter", + "gRPC", + "http", + "opentelemetry", + "otel", + "otlp", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-02-05T09:44:52+00:00" + }, + { + "name": "open-telemetry/gen-otlp-protobuf", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", + "reference": "66f04d0e448ad333033bfc7baae1aa56330be088" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/66f04d0e448ad333033bfc7baae1aa56330be088", + "reference": "66f04d0e448ad333033bfc7baae1aa56330be088", + "shasum": "" + }, + "require": { + "google/protobuf": "^3.22 || ^4.0 || ^5.0", + "php": "^8.0" + }, + "suggest": { + "ext-protobuf": "For better performance, when dealing with the protobuf format" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opentelemetry\\Proto\\": "Opentelemetry/Proto/", + "GPBMetadata\\Opentelemetry\\": "GPBMetadata/Opentelemetry/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "PHP protobuf files for communication with OpenTelemetry OTLP collectors/servers.", + "keywords": [ + "Metrics", + "apm", + "gRPC", + "logging", + "opentelemetry", + "otel", + "otlp", + "protobuf", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-06-17T12:06:32+00:00" + }, + { + "name": "open-telemetry/sdk", + "version": "1.15.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/sdk.git", + "reference": "77e1aa73850154abb86937d52a70883edc3b4547" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/77e1aa73850154abb86937d52a70883edc3b4547", + "reference": "77e1aa73850154abb86937d52a70883edc3b4547", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nyholm/psr7-server": "^1.1", + "open-telemetry/api": "^1.8", + "open-telemetry/context": "^1.4", + "open-telemetry/sem-conv": "^1.38.0", + "php": "^8.1", + "php-http/discovery": "^1.14", + "psr/http-client": "^1.0", + "psr/http-client-implementation": "^1.0", + "psr/http-factory-implementation": "^1.0", + "psr/http-message": "^1.0.1|^2.0", + "psr/log": "^1.1|^2.0|^3.0", + "ramsey/uuid": "^3.0 || ^4.0", + "symfony/polyfill-mbstring": "^1.23", + "symfony/polyfill-php82": "^1.26", + "tbachert/spi": "^1.0.5" + }, + "suggest": { + "ext-gmp": "To support unlimited number of synchronous metric readers", + "ext-mbstring": "To increase performance of string operations", + "open-telemetry/sdk-configuration": "File-based OpenTelemetry SDK configuration" + }, + "type": "library", + "extra": { + "spi": { + "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [ + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig", + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig", + "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySemConv", + "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySpanKind", + "OpenTelemetry\\SDK\\ConfigEnv\\Distribution\\DistributionConfigurationSdk" + ], + "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [ + "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver" + ], + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" + ] + }, + "branch-alias": { + "dev-main": "1.14.x-dev" + } + }, + "autoload": { + "files": [ + "Common/Util/functions.php", + "Logs/Exporter/_register.php", + "Metrics/MetricExporter/_register.php", + "Propagation/_register.php", + "Trace/SpanExporter/_register.php", + "Common/Dev/Compatibility/_load.php", + "_autoload.php" + ], + "psr-4": { + "OpenTelemetry\\SDK\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "SDK for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "apm", + "logging", + "opentelemetry", + "otel", + "sdk", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-07-14T13:09:54+00:00" + }, + { + "name": "open-telemetry/sem-conv", + "version": "1.44.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/sem-conv.git", + "reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/c94bc7ebacef039cda4f4c9270807dcc58b4e3af", + "reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenTelemetry\\SemConv\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "Semantic conventions for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "apm", + "logging", + "opentelemetry", + "otel", + "semantic conventions", + "semconv", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-09-09T22:06:17+00:00" + }, + { + "name": "orchestra/canvas", + "version": "v11.1.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/canvas.git", + "reference": "d13e6ec66cb1a285fcc7f092c804abaaf2e24ede" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/canvas/zipball/d13e6ec66cb1a285fcc7f092c804abaaf2e24ede", + "reference": "d13e6ec66cb1a285fcc7f092c804abaaf2e24ede", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", + "illuminate/console": "^13.23.0", + "illuminate/database": "^13.23.0", + "illuminate/filesystem": "^13.23.0", + "illuminate/support": "^13.23.0", + "orchestra/canvas-core": "^11.0.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^11.0.0", + "php": "^8.3", + "symfony/yaml": "^7.4.0|^8.0.0" + }, + "require-dev": { + "laravel/doctor": "^0.1.0", + "laravel/framework": "^13.23.0", + "laravel/pint": "^1.24", + "mockery/mockery": "^1.6.10", + "phpstan/phpstan": "^2.1.14", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0" + }, + "bin": [ + "canvas" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Orchestra\\Canvas\\LaravelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Orchestra\\Canvas\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" + } + ], + "description": "Code Generators for Laravel Applications and Packages", + "support": { + "issues": "https://github.com/orchestral/canvas/issues", + "source": "https://github.com/orchestral/canvas/tree/v11.1.0" + }, + "time": "2026-08-04T15:06:43+00:00" + }, + { + "name": "orchestra/canvas-core", + "version": "v11.0.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/canvas-core.git", + "reference": "88d091ff989748e2ca447bca0cd06ab14671ba82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/canvas-core/zipball/88d091ff989748e2ca447bca0cd06ab14671ba82", + "reference": "88d091ff989748e2ca447bca0cd06ab14671ba82", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", + "illuminate/console": "^13.0", + "illuminate/support": "^13.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "php": "^8.3" + }, + "require-dev": { + "laravel/framework": "^13.0", + "laravel/pint": "^1.24", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^11.0", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Orchestra\\Canvas\\Core\\LaravelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Orchestra\\Canvas\\Core\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" + } + ], + "description": "Code Generators Builder for Laravel Applications and Packages", + "support": { + "issues": "https://github.com/orchestral/canvas/issues", + "source": "https://github.com/orchestral/canvas-core/tree/v11.0.0" + }, + "time": "2026-03-16T15:10:50+00:00" + }, + { + "name": "orchestra/sidekick", + "version": "v1.2.23", + "source": { + "type": "git", + "url": "https://github.com/orchestral/sidekick.git", + "reference": "cbdfe583afc069be2ca6790489e9dda007d14d00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/sidekick/zipball/cbdfe583afc069be2ca6790489e9dda007d14d00", + "reference": "cbdfe583afc069be2ca6790489e9dda007d14d00", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", + "php": "^8.1", + "symfony/polyfill-php83": "^1.32" + }, + "require-dev": { + "fakerphp/faker": "^1.21", + "laravel/framework": "^10.48.29|^11.44.7|^12.1.1|^13.0", + "laravel/pint": "^1.4", + "mockery/mockery": "^1.5.1", + "orchestra/testbench-core": "^8.37.0|^9.14.0|^10.2.0|^11.0", + "phpstan/phpstan": "2.2.14", + "phpunit/phpunit": "^10.0|^11.0|^12.0|^13.0", + "symfony/process": "^6.0|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Eloquent/functions.php", + "src/Filesystem/functions.php", + "src/Http/functions.php", + "src/functions.php" + ], + "psr-4": { + "Orchestra\\Sidekick\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" + } + ], + "description": "Packages Toolkit Utilities and Helpers for Laravel", + "support": { + "issues": "https://github.com/orchestral/sidekick/issues", + "source": "https://github.com/orchestral/sidekick/tree/v1.2.23" + }, + "time": "2026-09-29T13:27:26+00:00" + }, + { + "name": "orchestra/testbench", + "version": "v11.3.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/testbench.git", + "reference": "7081895e5bbbdf2fd333fdf34e8ddec6cd4db713" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/testbench/zipball/7081895e5bbbdf2fd333fdf34e8ddec6cd4db713", + "reference": "7081895e5bbbdf2fd333fdf34e8ddec6cd4db713", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "fakerphp/faker": "^1.23", + "laravel/framework": "^13.32.0", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^11.5.0", + "orchestra/workbench": "^11.3.0", + "php": "^8.3", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "symfony/process": "^7.4.5|^8.0.5", + "symfony/yaml": "^7.4|^8.0", + "vlucas/phpdotenv": "^5.6.1" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com", + "homepage": "https://github.com/crynobone" + } + ], + "description": "Laravel Testing Helper for Packages Development", + "homepage": "https://packages.tools/testbench/", + "keywords": [ + "BDD", + "TDD", + "dev", + "laravel", + "laravel-packages", + "testing" + ], + "support": { + "issues": "https://github.com/orchestral/testbench/issues", + "source": "https://github.com/orchestral/testbench/tree/v11.3.0" + }, + "time": "2026-09-24T07:19:57+00:00" + }, + { + "name": "orchestra/testbench-core", + "version": "v11.6.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/testbench-core.git", + "reference": "f139a4ee74638dc24e0b6f657b47226c8ba5ab05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/f139a4ee74638dc24e0b6f657b47226c8ba5ab05", + "reference": "f139a4ee74638dc24e0b6f657b47226c8ba5ab05", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "orchestra/sidekick": "~1.1.25|~1.2.22", + "php": "^8.3", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-php84": "^1.34.0" + }, + "conflict": { + "brianium/paratest": "<7.3.0|>=8.0.0", + "laravel/framework": "<13.35.0|>=14.0.0", + "laravel/serializable-closure": ">=2.0.0 <2.0.10|>=3.0.0", + "nunomaduro/collision": "<8.9.0|>=9.0.0", + "phpunit/phpunit": "<11.5.50|>=12.0.0 <12.5.8|>=13.5.0" + }, + "require-dev": { + "fakerphp/faker": "^1.24", + "laravel/framework": "^13.35.0", + "laravel/pint": "^1.24", + "laravel/serializable-closure": "^2.0.10", + "mockery/mockery": "^1.6.10", + "phpstan/phpstan": "2.2.16", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "spatie/laravel-ray": "^1.43.6", + "symfony/process": "^7.4.5|^8.0.5", + "symfony/yaml": "^7.4.0|^8.0.0", + "vlucas/phpdotenv": "^5.6.1" + }, + "suggest": { + "brianium/paratest": "Allow using parallel testing (^7.3).", + "ext-pcntl": "Required to use all features of the console signal trapping.", + "fakerphp/faker": "Allow using Faker for testing (^1.23).", + "laravel/framework": "Required for testing (^13.35.0).", + "mockery/mockery": "Allow using Mockery for testing (^1.6).", + "nunomaduro/collision": "Allow using Laravel style tests output and parallel testing (^8.9).", + "orchestra/testbench-dusk": "Allow using Laravel Dusk for testing (^11.0).", + "phpunit/phpunit": "Allow using PHPUnit for testing (^11.5.50|^12.5.8|^13.0.0).", + "symfony/process": "Required to use Orchestra\\Testbench\\remote function (^7.4|^8.0).", + "symfony/yaml": "Required for Testbench CLI (^7.4|^8.0).", + "vlucas/phpdotenv": "Required for Testbench CLI (^5.6.1)." + }, + "bin": [ + "testbench" + ], + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Orchestra\\Testbench\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com", + "homepage": "https://github.com/crynobone" + } + ], + "description": "Testing Helper for Laravel Development", + "homepage": "https://packages.tools/testbench", + "keywords": [ + "BDD", + "TDD", + "dev", + "laravel", + "laravel-packages", + "testing" + ], + "support": { + "issues": "https://github.com/orchestral/testbench/issues", + "source": "https://github.com/orchestral/testbench-core" + }, + "time": "2026-10-07T23:31:28+00:00" + }, + { + "name": "orchestra/workbench", + "version": "v11.3.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/workbench.git", + "reference": "660046639af4538d6c56373c9222ca4c93055c42" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/workbench/zipball/660046639af4538d6c56373c9222ca4c93055c42", + "reference": "660046639af4538d6c56373c9222ca4c93055c42", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "fakerphp/faker": "^1.23", + "laravel/framework": "^13.0.0", + "laravel/pail": "^1.2.5", + "laravel/tinker": "^3.0.0", + "nunomaduro/collision": "^8.9", + "orchestra/canvas": "^11.0.1", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^11.5.0", + "php": "^8.3", + "symfony/process": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "require-dev": { + "laravel/pint": "^1.22.0", + "mockery/mockery": "^1.6.12", + "phpstan/phpstan": "^2.1.33", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "spatie/laravel-ray": "^1.43.6" + }, + "suggest": { + "ext-pcntl": "Required to use all features of the console signal trapping." + }, + "type": "library", + "autoload": { + "psr-4": { + "Orchestra\\Workbench\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" + } + ], + "description": "Workbench Companion for Laravel Packages Development", + "keywords": [ + "dev", + "laravel", + "laravel-packages", + "testing" + ], + "support": { + "issues": "https://github.com/orchestral/workbench/issues", + "source": "https://github.com/orchestral/workbench/tree/v11.3.0" + }, + "time": "2026-09-23T23:52:50+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2026-08-24T00:54:40+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7440368d94738d54f2c1de24dbd3871793bbb0dd", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.9.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0.1", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-10-03T14:03:50+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:40:53+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/babab73b7ef9fae406f2eb3ee11323a61caa63d5", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-10-03T04:51:29+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.40", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "76bd3198f16aeb375a4d5f3626471445aed0966e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/76bd3198f16aeb375a4d5f3626471445aed0966e", + "reference": "76bd3198f16aeb375a4d5f3626471445aed0966e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.8", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.1", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.1", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.40" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-16T05:55:07+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.4", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.4" + }, + "time": "2026-09-16T11:39:30+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "9e935a16187e14f39b0e79910dabd614972cdb26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/9e935a16187e14f39b0e79910dabd614972cdb26", + "reference": "9e935a16187e14f39b0e79910dabd614972cdb26", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-10-04T05:50:08+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "07437f612dca89c1894c1db04491002b1e798f03" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/07437f612dca89c1894c1db04491002b1e798f03", + "reference": "07437f612dca89c1894c1db04491002b1e798f03", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T14:31:36+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/21f1bc2296f7ff6043241a8dcbc09559b3732820", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T18:45:38+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-22T18:56:40+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-26T06:31:10+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", + "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-08T16:56:12+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "4779fce7dedacb8801b2b61c703ef99159502331" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/4779fce7dedacb8801b2b61c703ef99159502331", + "reference": "4779fce7dedacb8801b2b61c703ef99159502331", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-14T06:44:08+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/6f6f2441bef07a42d2617b9b3aadf04a321aa514", + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-15T06:01:15+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-04T10:45:44+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T14:40:04+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T15:19:04+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:01:09+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/8840df45b24f33a49dc419fa18dc0057231ae1d8", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T05:20:52+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-08T16:56:12+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/7a344cf871532d081875bfdd18f3dff8dca36c5b", + "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-11T18:40:59+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-03T15:15:30+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "3e706a245b5343ea93be9cafe84525bfb54e60fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/3e706a245b5343ea93be9cafe84525bfb54e60fc", + "reference": "3e706a245b5343ea93be9cafe84525bfb54e60fc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T14:31:36+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/55d06b02f49eca9bff61828548988e6f8763e8a6", + "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-13T05:48:33+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "044907929c7999814378be22da34722c7a86bad4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/044907929c7999814378be22da34722c7a86bad4", + "reference": "044907929c7999814378be22da34722c7a86bad4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T12:34:30+00:00" + }, + { + "name": "tbachert/spi", + "version": "v1.0.5", + "source": { + "type": "git", + "url": "https://github.com/Nevay/spi.git", + "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nevay/spi/zipball/e7078767866d0a9e0f91d3f9d42a832df5e39002", + "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "composer/semver": "^1.0 || ^2.0 || ^3.0", + "php": "^8.1" + }, + "require-dev": { + "composer/composer": "^2.0", + "infection/infection": "^0.27.9", + "phpunit/phpunit": "^10.5", + "psalm/phar": "^5.18" + }, + "type": "composer-plugin", + "extra": { + "class": "Nevay\\SPI\\Composer\\Plugin", + "branch-alias": { + "dev-main": "1.0.x-dev" + }, + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Nevay\\SPI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Service provider loading facility", + "keywords": [ + "service provider" + ], + "support": { + "issues": "https://github.com/Nevay/spi/issues", + "source": "https://github.com/Nevay/spi/tree/v1.0.5" + }, + "time": "2025-06-29T15:42:06+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/phpdotenv/phpdotenv.git", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.2", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-08-24T18:07:49+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.3", + "ext-pdo_mysql": "*", + "ext-redis": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/.github/tracing-performance/cpu-snapshot.sh b/.github/tracing-performance/cpu-snapshot.sh new file mode 100644 index 000000000..a24b0618c --- /dev/null +++ b/.github/tracing-performance/cpu-snapshot.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +project=${1:?Select the experiment Compose project} +[[ "$project" =~ ^[a-z0-9][a-z0-9-]{0,62}$ ]] +mapfile -t containers < <(docker ps -q --filter "label=com.docker.compose.project=$project") +[[ "${#containers[@]}" -eq 9 ]] +declare -A role_counts=([mysql]=0 [redis]=0 [app]=0 [worker]=0 [collector]=0 [probe]=0) + +for container in "${containers[@]}"; do + read -r process_id role full_id < <(docker inspect --format '{{.State.Pid}} {{index .Config.Labels "com.docker.compose.service"}} {{.Id}}' "$container") + [[ "$process_id" =~ ^[1-9][0-9]*$ ]] + [[ "$role" =~ ^(mysql|redis|app|worker|collector|probe)$ ]] + role_counts[$role]=$((role_counts[$role] + 1)) + group=$(awk -F: '$1 == "0" {print $3}' "/proc/$process_id/cgroup") + [[ "$group" == /* && "$group" != *..* ]] + limits="/sys/fs/cgroup$group" + jq -cn --arg container "$full_id" --arg role "$role" \ + --arg sampled_at "$(date -u '+%Y-%m-%dT%H:%M:%S.%NZ')" \ + --rawfile cpu "$limits/cpu.stat" --rawfile quota "$limits/cpu.max" \ + --rawfile memory_events "$limits/memory.events" \ + --rawfile memory_current "$limits/memory.current" \ + --rawfile memory_peak "$limits/memory.peak" \ + --rawfile memory_limit "$limits/memory.max" \ + --rawfile swap_limit "$limits/memory.swap.max" \ + '{container:$container,role:$role,sampled_at:$sampled_at,cpu:$cpu,cpu_max:$quota, + memory_events:$memory_events,memory_max:$memory_limit,swap_max:$swap_limit, + memory_current_bytes:($memory_current|tonumber),lifetime_memory_peak_bytes:($memory_peak|tonumber)}' +done + +[[ "${role_counts[worker]}" -eq 4 ]] +for role in mysql redis app collector probe; do + [[ "${role_counts[$role]}" -eq 1 ]] +done diff --git a/.github/tracing-performance/probe.mjs b/.github/tracing-performance/probe.mjs new file mode 100644 index 000000000..c99ff70aa --- /dev/null +++ b/.github/tracing-performance/probe.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {readFile, writeFile} from 'node:fs/promises'; +import {performance} from 'node:perf_hooks'; + +const base = process.env.BASE_URL ?? 'http://app:8080'; +const profile = process.env.PROFILE_NAME; +const members = Number(process.env.MEMBERS ?? 1); +const workloads = JSON.parse(await readFile(new URL('./workloads.json', import.meta.url), 'utf8')); +const defaults = workloads[members === 1 ? 'ordinary' : 'fanout']; +const workload = process.env.WORKLOAD_NAME || (members === 1 ? 'ordinary' : 'fanout'); +const rate = Number(process.env.OFFERED_RATE || 1000 / defaults.interval_ms); +const duration = Number(process.env.DURATION_SECONDS || defaults.duration_seconds); +const observation = 90_000; +const diagnostic = process.env.DIAGNOSTIC_ONLY === 'true'; +const queueDriver = process.env.BENCH_QUEUE_DRIVER ?? 'database'; +const allowedLagMs = 100; +assert.match(profile ?? '', /^[a-z0-9-]{1,55}$/); +assert.match(workload, /^[a-z0-9-]{1,55}$/); +assert.ok([1, 64].includes(members) && rate > 0 && duration > 0); +assert.equal(defaults.members, members); +assert.ok(['database', 'redis'].includes(queueDriver)); +const health = await fetch(base + '/health', {signal: AbortSignal.timeout(10_000)}); +assert.equal(health.status, 200); +const deployment = await health.json(); +assert.equal(deployment.ready, true); +assert.equal(deployment.queue_driver, queueDriver, 'Expected queue driver.'); +const queueWakeEnabled = deployment.queue_wake_enabled === true; +if (diagnostic) { + assert.equal(typeof deployment.queue_wake_enabled, 'boolean', 'Expected boolean queue-wake evidence.'); + assert.ok(profile.startsWith('diagnostic-')); +} else { + assert.equal(queueWakeEnabled, true, 'Standard profile requires automatic queue wake.'); +} +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const began = performance.now(); +const beganAt = Date.now(); +const outcomes = []; +const requestTimes = []; +let maximumLagMs = 0; +let missedOffers = 0; + +async function request(path, deadline, input, traceparent) { + const begin = performance.now(); + const remaining = deadline - begin; + assert.ok(remaining > 0, 'Original workflow observer expired before request.'); + const response = await fetch(base + path, { + method: input ? 'POST' : 'GET', + headers: {'Content-Type': 'application/json', traceparent}, + body: input ? JSON.stringify(input) : undefined, + signal: AbortSignal.timeout(Math.max(1, Math.floor(Math.min(10_000, remaining)))), + }); + const text = await response.text(); + requestTimes.push({ms: performance.now() - begin, status: response.status}); + assert.equal(response.status, 200, `HTTP ${response.status}: ${text.slice(0, 300)}`); + return {data: JSON.parse(text), text}; +} + +async function original(index) { + const id = `${workload}-${index}`; + const start = performance.now(); + const deadline = start + observation; + const hash = createHash('sha256').update(id).digest('hex'); + const traceparent = `00-${hash.slice(0, 32)}-${hash.slice(32, 48)}-01`; + const outcome = {id, completed: false}; + outcomes.push(outcome); + try { + const {data: admitted} = await request('/workflows', deadline, {id, members}, traceparent); + assert.equal(admitted.id, id); + assert.ok(admitted.run_id && admitted.execution_deadline && admitted.run_deadline); + outcome.run_id = admitted.run_id; + while (performance.now() < deadline) { + const {data: current, text} = await request(`/workflows/${id}`, deadline, undefined, traceparent); + for (const field of ['id', 'run_id', 'execution_deadline', 'run_deadline']) { + assert.equal(current[field], admitted[field]); + } + if (current.status === 'completed') { + assert.equal(current.output.length, members); + for (let member = 0; member < members; member++) { + assert.deepEqual(Object.keys(current.output[member]), ['member', 'value']); + assert.equal(current.output[member].member, member); + assert.equal(current.output[member].value.length, 3); + assert.equal(typeof current.output[member].value[0], 'number'); + assert.equal(current.output[member].value[1], null); + assert.equal(current.output[member].value[2], '雪'); + } + assert.equal(text.match(/9007199254740993/g)?.length, members); + outcome.completed = true; + outcome.latency_ms = performance.now() - start; + return; + } + assert.ok(['pending', 'running', 'waiting'].includes(current.status), `Unexpected ${current.status}`); + await sleep(250); + } + throw new Error('Original 90-second workflow observer expired.'); + } catch (error) { + outcome.error = String(error).slice(0, 500); + } +} + +const pending = []; +const offeredTarget = Math.floor(rate * duration); +assert.ok(offeredTarget > 0); +for (let index = 0; index < offeredTarget; index++) { + const due = began + index * 1000 / rate; + await sleep(Math.max(0, due - performance.now())); + maximumLagMs = Math.max(maximumLagMs, performance.now() - due); + if (performance.now() - due > allowedLagMs || performance.now() >= began + duration * 1000) { + ++missedOffers; + continue; + } + pending.push(original(index)); +} +await sleep(Math.max(0, began + duration * 1000 - performance.now())); +const completedAtOfferEnd = outcomes.filter(item => item.completed).length; +await Promise.all(pending); +const completed = outcomes.filter(item => item.completed); +const sorted = completed.map(item => item.latency_ms).sort((a, b) => a - b); +const percentile = p => sorted[Math.max(0, Math.ceil(sorted.length * p) - 1)] ?? null; +const result = { + profile, workload, members, rate, offered_target: offeredTarget, offered: pending.length, missed_offers: missedOffers, + diagnostic_only: diagnostic, queue_wake_enabled: queueWakeEnabled, + queue_driver: queueDriver, + accepted: outcomes.filter(item => item.run_id).length, + completed: completed.length, completed_at_offer_end: completedAtOfferEnd, + started_at_ms: beganAt, finished_at_ms: Date.now(), + duration_seconds: duration, observer_seconds: observation / 1000, + elapsed_seconds: (performance.now() - began) / 1000, maximum_schedule_lag_ms: maximumLagMs, + p50_ms: percentile(0.5), p95_ms: percentile(0.95), p99_ms: percentile(0.99), + http_requests: requestTimes.length, http_errors: requestTimes.filter(item => item.status !== 200).length, + process_usage: process.resourceUsage(), failures: outcomes.filter(item => !item.completed), +}; +await writeFile(`/results/${profile}.json`, JSON.stringify(result)); +console.log(JSON.stringify(result)); +assert.equal(result.missed_offers, 0); +assert.equal(result.offered, offeredTarget); +assert.equal(result.accepted, offeredTarget); +assert.equal(result.completed, offeredTarget); +assert.equal(result.http_errors, 0); diff --git a/.github/tracing-performance/probe.test.mjs b/.github/tracing-performance/probe.test.mjs new file mode 100644 index 000000000..f6d527a5c --- /dev/null +++ b/.github/tracing-performance/probe.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import {execFile} from 'node:child_process'; +import {createServer} from 'node:http'; +import test from 'node:test'; +import {fileURLToPath} from 'node:url'; +import {promisify} from 'node:util'; + +const execute = promisify(execFile); +const probe = fileURLToPath(new URL('./probe.mjs', import.meta.url)); + +async function refusesBeforeAdmission(t, health, diagnostic, message) { + let submissions = 0; + const server = createServer((request, response) => { + if (request.method === 'POST') submissions++; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({queue_driver: 'database', ...health})); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + }); + await assert.rejects(execute(process.execPath, [probe], { + timeout: 5000, + env: {...process.env, BASE_URL: `http://127.0.0.1:${server.address().port}`, + PROFILE_NAME: 'quality-refusal', MEMBERS: '1', OFFERED_RATE: '1', + DURATION_SECONDS: '1', DIAGNOSTIC_ONLY: String(diagnostic), BENCH_QUEUE_DRIVER: 'database'}, + }), error => { + assert.equal(error.code, 1); + assert.match(error.stderr, message); + return true; + }); + assert.equal(submissions, 0); +} + +for (const [name, metadata] of [ + ['explicitly disabled', {ready: true, queue_wake_enabled: false}], + ['missing', {ready: true}], + ['nonboolean', {ready: true, queue_wake_enabled: 'true'}], +]) { + test(`standard profile refuses ${name} queue-wake evidence`, async t => { + await refusesBeforeAdmission(t, metadata, false, /Standard profile requires automatic queue wake/); + }); +} + +test('diagnostic opt-in requires a diagnostic profile label', async t => { + await refusesBeforeAdmission(t, {ready: true, queue_wake_enabled: false}, true, /diagnostic-/); +}); + +test('diagnostic profile refuses nonboolean queue-wake evidence before admission', async t => { + await refusesBeforeAdmission(t, {ready: true, queue_wake_enabled: 'false'}, + true, /Expected boolean queue-wake evidence/); +}); + +test('standard profile refuses another queue driver before admission', async t => { + await refusesBeforeAdmission(t, {ready: true, queue_wake_enabled: true, queue_driver: 'redis'}, + false, /Expected queue driver/); +}); diff --git a/.github/tracing-performance/retain-process.sh b/.github/tracing-performance/retain-process.sh new file mode 100644 index 000000000..53335b3ac --- /dev/null +++ b/.github/tracing-performance/retain-process.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -u + +: "${BENCH_STATE:?Select disposable state}" "${BENCH_ROLE:?Select worker or probe}" "${HOSTNAME:?Missing container identity}" +case "$BENCH_ROLE" in worker|probe) ;; *) exit 2 ;; esac +case "$#" in 0) exit 2 ;; *) ;; esac + +if [ -n "${BENCH_RELEASE_FILE:-}" ]; then + while [ ! -f "$BENCH_RELEASE_FILE" ]; do sleep 0.05; done +fi + +"$@" +retained_status=$? +printf '%s\n' "$retained_status" > "$BENCH_STATE/$BENCH_ROLE-$HOSTNAME.exit" + +# Keep the cgroup readable after the child and its provider shutdown finish. +exec sleep infinity diff --git a/.github/tracing-performance/src/BenchmarkActivity.php b/.github/tracing-performance/src/BenchmarkActivity.php new file mode 100644 index 000000000..d52b9bf46 --- /dev/null +++ b/.github/tracing-performance/src/BenchmarkActivity.php @@ -0,0 +1,24 @@ +insert([ + 'activity_id' => $this->activityId(), + 'run_id' => $this->runId(), + 'member' => $member, + ]); + Log::info('benchmark callback', ['member' => $member]); + + return ['member' => $member, 'value' => [9007199254740993, null, '雪']]; + } +} diff --git a/.github/tracing-performance/src/BenchmarkApplication.php b/.github/tracing-performance/src/BenchmarkApplication.php new file mode 100644 index 000000000..c6913bd60 --- /dev/null +++ b/.github/tracing-performance/src/BenchmarkApplication.php @@ -0,0 +1,64 @@ +set([ + 'database.default' => 'benchmark', + 'database.connections.benchmark' => [ + 'driver' => 'mysql', 'host' => getenv('DB_HOST') ?: 'mysql', 'port' => 3306, + 'database' => getenv('DB_DATABASE') ?: 'benchmark', + 'username' => getenv('DB_USERNAME') ?: 'benchmark', + 'password' => getenv('DB_PASSWORD') ?: 'disposable-benchmark', + 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', 'strict' => true, + ], + 'workflows.storage.connection' => 'benchmark', + 'workflows.v1.enabled' => false, + 'workflows.v2.compatibility.namespace' => 'default', + 'workflows.v2.task_dispatch_mode' => 'queue', + 'workflows.v2.matching_role.queue_wake_enabled' => getenv('BENCH_QUEUE_WAKE') !== 'false', + 'workflows.tracing.enabled' => $enabled, + 'workflows.tracing.baggage_allowlist' => [], + 'queue.default' => $queue, + 'queue.connections.database' => [ + 'driver' => 'database', 'connection' => 'benchmark', 'table' => 'jobs', + 'queue' => 'default', 'retry_after' => 130, + ], + 'queue.connections.redis' => [ + 'driver' => 'redis', 'connection' => 'default', + 'queue' => 'default', 'retry_after' => 130, 'block_for' => 1, + ], + 'queue.failed' => [ + 'driver' => 'database-uuids', 'database' => 'benchmark', 'table' => 'failed_jobs', + ], + 'database.redis.client' => 'phpredis', + 'database.redis.default' => ['host' => getenv('REDIS_HOST') ?: 'redis', 'port' => 6379], + 'database.redis.cache' => ['host' => getenv('REDIS_HOST') ?: 'redis', 'port' => 6379, 'database' => 1], + 'cache.default' => 'redis', + 'cache.prefix' => 'tracing-benchmark:', + 'cache.stores.redis' => ['driver' => 'redis', 'connection' => 'cache', 'lock_connection' => 'cache'], + 'workflows.v2.long_poll.multi_node' => true, + 'workflows.v2.long_poll.validation_mode' => 'fail', + 'logging.default' => 'benchmark', + 'logging.channels.benchmark' => [ + 'driver' => 'single', 'path' => $app->basePath('callbacks.log'), + 'formatter' => JsonFormatter::class, 'tap' => $enabled ? [LogContext::class] : [], + ], + ]); + } +} diff --git a/.github/tracing-performance/src/BenchmarkWorkflow.php b/.github/tracing-performance/src/BenchmarkWorkflow.php new file mode 100644 index 000000000..179aa8747 --- /dev/null +++ b/.github/tracing-performance/src/BenchmarkWorkflow.php @@ -0,0 +1,24 @@ + self::activity(BenchmarkActivity::class, $member); + } + + return self::all($calls); + } +} diff --git a/.github/tracing-performance/src/CountingProcessor.php b/.github/tracing-performance/src/CountingProcessor.php new file mode 100644 index 000000000..e078526d8 --- /dev/null +++ b/.github/tracing-performance/src/CountingProcessor.php @@ -0,0 +1,58 @@ +processor->onStart($span, $parentContext); + } + + public function onEnd(ReadableSpanInterface $span): void + { + ++$this->ended; + $this->processor->onEnd($span); + } + + public function forceFlush(?\OpenTelemetry\SDK\Common\Future\CancellationInterface $cancellation = null): bool + { + return $this->processor->forceFlush($cancellation); + } + + public function shutdown(?\OpenTelemetry\SDK\Common\Future\CancellationInterface $cancellation = null): bool + { + if ($this->stopped) { + return false; + } + $this->stopped = true; + try { + return $this->processor->shutdown($cancellation); + } finally { + $usage = getrusage(); + file_put_contents(getenv('BENCH_STATE') . '/ended.jsonl', json_encode([ + 'instance' => gethostname(), 'pid' => getmypid(), + 'role' => PHP_SAPI === 'cli-server' ? 'http' : 'worker', + 'ended' => $this->ended, + 'cpu_user_seconds' => $usage['ru_utime.tv_sec'] + $usage['ru_utime.tv_usec'] / 1_000_000, + 'cpu_system_seconds' => $usage['ru_stime.tv_sec'] + $usage['ru_stime.tv_usec'] / 1_000_000, + 'maxrss_kib' => $usage['ru_maxrss'], + ], JSON_THROW_ON_ERROR) . PHP_EOL, FILE_APPEND | LOCK_EX); + } + } +} diff --git a/.github/tracing-performance/workloads.json b/.github/tracing-performance/workloads.json new file mode 100644 index 000000000..bfec23927 --- /dev/null +++ b/.github/tracing-performance/workloads.json @@ -0,0 +1,4 @@ +{ + "ordinary": {"members": 1, "interval_ms": 1000, "duration_seconds": 120}, + "fanout": {"members": 64, "interval_ms": 60000, "duration_seconds": 120} +} diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 742ba3dc2..34b8d8465 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -261,6 +261,15 @@ jobs: - name: Validate composer.json and composer.lock run: composer validate --strict + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + + - name: Check tracing overhead recipe + run: | + composer --working-dir=.github/tracing-performance validate --strict --no-check-all --no-check-publish + node --test .github/tracing-performance/probe.test.mjs .github/tracing-performance/analyze.test.mjs + - name: Cache Composer packages uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: @@ -316,6 +325,42 @@ jobs: if-no-files-found: error retention-days: 90 + tracing-http: + needs: preflight + runs-on: ubuntu-latest + timeout-minutes: 8 + env: + OTEL_PHP_FIBERS_ENABLED: 'false' + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up the optional application instrumentation + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.5' + extensions: pdo_sqlite, opentelemetry-1.4.2 + tools: composer + coverage: none + env: + fail-fast: true + + - name: Install ordinary package and client instrumentation dependencies + run: | + composer install --prefer-dist --no-progress + composer install --working-dir=.github/tracing-http --prefer-dist --no-progress --no-plugins --no-scripts + composer audit --working-dir=.github/tracing-http + + - name: Verify real HTTP propagation through independent activity workers + run: >- + vendor/bin/phpunit --no-configuration + --bootstrap .github/tracing-http/bootstrap.php + --do-not-record-test-run-history --fail-on-warning --fail-on-skipped + tests/Unit/V2/HttpExecutionTracingTest.php + tests/Unit/V2/ExporterExecutionTracingTest.php + pr-unit-contracts: needs: preflight if: ${{ github.event_name == 'pull_request' }} @@ -383,6 +428,27 @@ jobs: --weight-profile=mysql --output-dir=build/pr-selection + - name: Run optional embedded tracing controls + run: >- + vendor/bin/phpunit tests/Unit/V2/ActivityExecutionTracingTest.php + tests/Unit/V2/IncomingHttpTracingTest.php + tests/Unit/V2/RecoveryExecutionTracingTest.php + tests/Unit/V2/TransitionExecutionTracingTest.php + tests/Unit/V2/FailureOutcomeExecutionTracingTest.php + tests/Unit/V2/FanOutExecutionTracingTest.php + tests/Unit/V2/LargeFanOutExecutionTracingTest.php + tests/Unit/V2/TaskOutcomeCarrierTest.php + tests/Unit/V2/ChildRetryExecutionTracingTest.php + tests/Unit/V2/CancellationExecutionTracingTest.php + tests/Unit/V2/SignalStartExecutionTracingTest.php + tests/Unit/V2/SignalLinkExecutionTracingTest.php + tests/Unit/V2/UpdateLinkExecutionTracingTest.php + tests/Unit/V2/TimingExecutionTracingTest.php + tests/Unit/V2/AdmissionExecutionTracingTest.php + tests/Unit/V2/DispatchExecutionTracingTest.php + tests/Unit/V2/ScheduleExecutionTracingTest.php + --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml + - name: Run database-independent unit contracts run: | mkdir -p build/test-results @@ -1376,6 +1442,7 @@ jobs: - preflight - regression-corpus - quality + - tracing-http - pr-unit-contracts - pr-feature-mysql - feature-mysql @@ -1396,6 +1463,9 @@ jobs: - name: Require repository preflight run: test "${{ needs.preflight.result }}" = "success" + - name: Require the actual HTTP tracing consumer + run: test "${{ needs.tracing-http.result }}" = "success" + - name: Check pull-request gate if: ${{ github.event_name == 'pull_request' }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7114a9818..5057048a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2.8.0 - Unreleased + +- Prepare optional embedded OpenTelemetry spans and log correlation across + durable admission, tasks, activities, retries and recovery. Applications own + providers, sampling, exporters and shutdown. Tracing is disabled by default. +- Keep bounded observability context separate from business payloads, command + equality and lease authority. Native catalogs and service wire adoption are + separately qualified. +- Require OpenTelemetry API 1.10.1 or later in the 1.x line when that optional + API is installed, excluding the older incompatible baggage parser. + ## 2.7.1 - 2026-10-20 - Chunk long activity retry and workflow-task delays to the queue driver's diff --git a/composer.json b/composer.json index 705541e69..baf80eed8 100644 --- a/composer.json +++ b/composer.json @@ -68,19 +68,26 @@ "require-dev": { "aws/aws-sdk-php": "^3.0", "doctrine/dbal": "^3.7", + "open-telemetry/api": "^1.10.1", + "open-telemetry/exporter-otlp": "^1.4", + "open-telemetry/sdk": "^1.0", "orchestra/testbench": "^8.0|^11.0", "phpstan/phpstan": "^2.0", "symplify/easy-coding-standard": "^11.0" }, + "conflict": { + "open-telemetry/api": "<1.10.1 || >=2.0" + }, "suggest": { - "durable-workflow/sdk": "Framework-neutral client and remote-worker SDK for the standalone Durable Workflow server." + "durable-workflow/sdk": "Framework-neutral client and remote-worker SDK for the standalone Durable Workflow server.", + "open-telemetry/api": "Optional application-owned trace propagation API, version 1.10.1 or later in the 1.x line." }, "extra": { "branch-alias": { "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.8.0", + "product-train": "2.9.0", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md new file mode 100644 index 000000000..5959bce1d --- /dev/null +++ b/docs/embedded-tracing.md @@ -0,0 +1,537 @@ +# Embedded tracing preparation + +This document describes the optional embedded tracing implementation being +qualified in the [tracing proposal](https://github.com/durable-workflow/workflow/issues/773). +It is not a published-package support claim. OpenTelemetry API 1.10.1 fixes the +encoded-value baggage regression and is the minimum supported API version. +Full qualification and a compatible Durable Workflow release are required +before enabling this in production. + +The application owns its OpenTelemetry providers, instrumentation, sampling, +exporters and process shutdown. Durable Workflow uses those providers when +`workflows.tracing.enabled` is true. It does not replace application providers or +flush an exporter after each task. Tracing is disabled by default. + +An unavailable tracing-enabled setting disables instrumentation for that +operation. An unavailable or non-array baggage allowlist permits no baggage; +only string entries are used. Neither optional setting can prevent admission, +timer or activity execution, or replace the workflow's own failure. + +Malformed, oversized, unsupported or unreadable carrier metadata is discarded. +Recording processing/admission spans report only the bounded attribute +`durable.trace_context.disposition=discarded`. When normalization retains a valid +parent but filters or canonicalizes its propagation headers, the value is +`filtered`. Ordinary absent legacy metadata has no diagnostic. Captured caller +and signal/update-link diagnostics can also annotate the current recording +application span. These attributes contain no header values, baggage keys or +values, payloads or exception messages, and do not mark business work as failed. +They add no durable field, history event, independent export or logging call. +Sampling and application SDK limits still apply: no diagnostic is guaranteed +when no span records, and disabled instrumentation emits none. +Invalid queue-dispatch link metadata uses the separate bounded attribute +`durable.queue_context.disposition`; dropping that link never replaces the +run's durable parent or task authority. + +## Configure the source integration + +In a qualification application, use one application-owned environment flag for +tracing and its optional log tap. Set `DW_TRACING_ENABLED=true` in that +application's environment, then configure the existing `tracing` section of +`config/workflows.php`: + +```php +'tracing' => [ + 'enabled' => (bool) env('DW_TRACING_ENABLED', false), + 'baggage_allowlist' => [], +], +``` + +The empty allowlist propagates no baggage. Add only the application-approved +keys that must cross durable boundaries. The application's installed +propagator must support those fields. Install OpenTelemetry API `^1.10.1` when +using tracing. Older APIs have an incompatible baggage parser, so Composer +rejects those versions. Applications without OpenTelemetry do not gain a new +required dependency. +The package reads the configured boolean; the environment mapping above is +part of the application example. + +Register the application's chosen OpenTelemetry provider and propagators +before admitting or processing workflows. Existing compatible HTTP/database +instrumentation then uses the active standard context inside an activity. +The application continues to own sampling, export and process shutdown. +The [optional HTTP dependency profile](../.github/tracing-http/composer.json) +shows the packages used by the source controls, with the exporter limits +described below. + +### Laravel log correlation + +Append the supported tap to each existing Laravel channel used by the workflow, +activity and injected services. In that channel's `config/logging.php` entry: + +```php +'tap' => [ + // Keep the channel's existing taps here. + ...((bool) env('DW_TRACING_ENABLED', false) + ? [\Workflow\V2\Tracing\LogContext::class] + : []), +], +``` + +Load this configuration before the channel is resolved. Choose a formatter +that emits the record's `context` fields, such as Monolog's `JsonFormatter`. +Enabling span emission alone does not install this channel tap. Application +code can keep its ordinary `Log::info(...)` calls and injected logger usage. +The [cold HTTP/worker control](../tests/Unit/V2/IncomingHttpTracingTest.php) +checks both actual callback logs and spans after the request process exits. + +The tap reads only the active durable scope. It restores ordinary logging +outside that scope and preserves an existing application `request_id`. Logging +adapter errors leave the original record usable. + +Restart the application's workers after changing their loaded configuration. +The overhead recipe uses this conditional tap profile. Always-installed custom +logging processors need their own measurement. + +## Correlation fields and instrumentation scope + +The tracing scope name is `durable-workflow/workflow`. Its version comes from +Composer's installed pretty version when available. A development or benchmark +alias remains that alias; it is not evidence of a published release. + +The current operation supplies these bounded fields when their identities are +available: + +| Field | Meaning | +| --- | --- | +| `workflow_instance_id` | Logical workflow identity, which can have multiple runs. | +| `workflow_run_id` | The exact run being admitted or processed. | +| `workflow_type` | The recorded workflow type. | +| `namespace` | The operation's namespace when scoped. | +| `task_id` | The current physical task. | +| `activity_id` | Logical activity identity, retained across its ordinary retries. | +| `activity_type` | The authored activity type. | +| `activity_attempt_id` | The concrete activity attempt when available. | +| `activity_attempt_number` | The positive attempt number. | +| `request_id` | Application-supplied request correlation, kept separate from durable identities. | +| `trace_id`, `span_id` | Active OpenTelemetry context identifiers, also exposed to the log tap. | +| `timer_id` | The timer associated with a timer operation. | +| `workflow_command_id` | The command associated with an admission or delivery. | +| `schedule_id` | The schedule associated with a trigger. | + +Trace and span IDs are part of OpenTelemetry's span context. They are not +duplicated as custom span attributes. Missing or invalid optional fields are +omitted. Runtime-supplied string correlation fields are bounded to 512 bytes +and exclude control characters; attempt numbers remain integers. Existing +application log context retains its application-defined format. + +Durable identifiers come from the current execution scope. An existing +`request_id` on a log record keeps its application meaning. These identifiers +are not automatically used as metric labels. The integration excludes workflow +arguments, results, HTTP bodies and credentials from its default attributes. +Application-chosen identifiers and explicitly allowlisted baggage still need +the access controls appropriate to their contents. + +## Runtime and SDK qualification + +This matrix describes this draft's durable propagation and correlation +contract. Ordinary application HTTP instrumentation has a separate scope. + +| Runtime or client | Scope established by this draft | Support status | +| --- | --- | --- | +| Embedded Laravel | Source controls cover incoming HTTP context, separate queue workers, retries, recovery and the transitions described below. | Unreleased. Remaining full gates and overhead acceptance still block stable support. | +| PHP Server | This embedded draft adds no service transport contract. | Separate integration and consumer qualification required. | +| Rust Server draft | Proposed observability columns and carriers have not been adopted by its catalog. | Unqualified for this contract. | +| Cloud | No deployment of this draft is claimed. | Separate release and deployment qualification required. | +| PHP service SDK | No durable carrier API or service propagation is added here. | Unqualified for this contract. | +| Python service SDK | No durable carrier API or service propagation is added here. | Unqualified for this contract. | +| Rust service SDK | No durable carrier API or service propagation is added here. | Unqualified for this contract. | + +The package's optional tracing API can be absent, and tracing is disabled by +default. In those modes the execution callback still runs with its ordinary +business outcome. Enabling this source requires review of its proposed nullable +`observability_context` columns on runs, commands and tasks. Existing rows keep +null metadata. Older executions use independent processing segments instead +of borrowing an unrelated worker's parent. Canonical histories, command receipt +comparisons, retry policies and deadlines retain their existing contracts. + +Run package migrations with the application's model configuration in place. +The carrier migrations update both default tables and existing tables selected +by `run_model`, `command_model` and `task_model` on the workflow storage +connection. They preserve existing rows and can be reapplied. Applications that +create replacement tables later must include these nullable JSON columns in +their schema. This is a schema upgrade even when tracing is disabled. + +Worker processes can handle successive jobs, but this integration scopes +context to the current operation. It does not keep one execution span open +for the lifetime of a worker session. Concurrent Fiber and cold-process controls +check the scope restoration described below. + +## Keep collector outages outside business work + +PHP exporters can perform synchronous HTTP requests. A batch processor alone +does not guarantee that ending a span is free of network waits. In the tested +SDK, the default batch processor can export from `onEnd()`. Its export-timeout +constructor argument does not impose a timeout on an arbitrary HTTP client. + +The tested application profile uses the official OTLP exporter and an explicit +PSR HTTP client. The client limits each request to 100 milliseconds, transport +retries are disabled, and the processor buffers at most four completed spans. +It exports only when the application explicitly flushes or shuts down: + +```php +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; +use OpenTelemetry\API\Common\Time\Clock; +use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\Context\Context; +use OpenTelemetry\Contrib\Otlp\ContentTypes; +use OpenTelemetry\Contrib\Otlp\SpanExporter; +use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory; +use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor; + +$http = new HttpFactory(); +$transport = (new PsrTransportFactory( + new Client(['timeout' => 0.1, 'connect_timeout' => 0.1]), + $http, + $http, +))->create($collectorEndpoint, ContentTypes::PROTOBUF, maxRetries: 0); + +// The processor captures this context for its own export calls. +$exportScope = Configurator::createNoop()->storeInContext(Context::getRoot())->activate(); +try { + $processor = new BatchSpanProcessor( + new SpanExporter($transport), + Clock::getDefault(), + maxQueueSize: 4, + maxExportBatchSize: 2, + autoFlush: false, + ); +} finally { + $exportScope->detach(); +} +``` + +Add the processor to the application's provider. The optional dependency profile +is in [`.github/tracing-http/composer.json`](../.github/tracing-http/composer.json). +It is separate from the package's required dependencies. +The temporary export context prevents client instrumentation from creating new +spans for collector requests. It restores the application's context immediately +after constructing the processor. + +Arrange bounded worker recycling and call the provider's `shutdown()` from the +application's process teardown after its worker loop returns. Flushing and +shutdown belong outside workflow replay, activity callbacks and database +transactions. Reserve time for every queued export batch in the supervisor's +shutdown grace period. Transport deadlines, batch count, retries and other +application processors all contribute to that budget. + +This small queue intentionally drops excess spans. Failed exports are reported +by OpenTelemetry's diagnostic logger and are not replayed as workflow work. +`forceFlush()` returning true alone is not proof that a collector accepted every +span. Observe exporter errors and collector ingestion separately. A slow or +unbounded custom processor can still block its caller. + +See the [official PHP exporter documentation](https://opentelemetry.io/docs/languages/php/exporters/) +and the tested SDK's [batch processor](https://github.com/opentelemetry-php/sdk/blob/77e1aa73850154abb86937d52a70883edc3b4547/Trace/SpanProcessor/BatchSpanProcessor.php) +and [HTTP transport factory](https://github.com/opentelemetry-php/sdk/blob/77e1aa73850154abb86937d52a70883edc3b4547/Common/Export/Http/PsrTransportFactory.php). + +## What this profile currently verifies + +Real HTTP controls cover a collector returning 503, a collector that accepts +the request but stalls, and a refused connection. Independent database queue +workers still complete the original workflow and activity once with exact +typed output, unchanged admission context and deadlines, and zero repairs. +No export request occurs before process teardown in these controls. + +Separate controls verify the four-span queue limit, overflow loss, failed-export +diagnostics, recovery through the same provider, original application exception +identity, context cleanup and repeated shutdown. The receiver decodes requests +with the official OTLP protobuf classes. These are functional checks, not +overhead, throughput or collector durability measurements. + +## Current boundaries + +Ordinary embedded starts emit a short `durable.workflow.admission` span when +tracing is enabled. It is an internal operation under the application's current +context. The span reports the returned start receipt, including a new run, +return of an existing active run or duplicate refusal. Unexpected application +exceptions retain their original identity. Optional telemetry failures do not +replace the admission result. + +The durable carrier is captured before this span starts. Later workers remain +children of that original caller, rather than the admission span. Duplicate +attempts have their own admission spans and keep the original run carrier and +deadlines. Span creation and completion occur outside the admission transaction. +The application owns processors, so a synchronous processor can still delay its +caller. The bounded-export guidance above also applies to admission spans. + +Embedded signal-with-start uses the same admission span. The primary command +attributes describe its signal receipt. If a start command was actually +recorded, `durable.start.command_id`, `durable.start.status` and +`durable.start.outcome` identify that paired decision. A rejected signal can +have no paired start command; tracing does not invent one. Each signal keeps +its captured caller for later delivery links, while an existing run keeps its +original parent and deadlines. Legacy and no-parent admissions are included in +the cold-process controls. Embedded schedule controls are described below; +service admission and wider runtime coverage remain unqualified. + +Workflow processing and activity attempts receive separate spans. Persisted +admission context survives the qualified cold-worker, child, continuation and +signal-with-start paths. Existing runs keep their original admission context. +An untraced legacy admission does not borrow a worker's ambient parent. + +An embedded local callback uses an internal `durable.local_activity.attempt` +span for its object construction, injected dependencies and handler. It inherits +the active hosting workflow task only when instance, run, namespace and task +all match. Otherwise it uses the stored admission parent, or a root for a legacy +run. Each retry has its own attempt span. History replay, refused attempts and +mocked results do not emit callback spans. Local attempts have no activity-queue +wait measurement. The span ends before outcome recording, and the existing +execution transaction and retry dispatch rules remain unchanged. + +Cold parallel-workflow controls use a local callback followed by a group of +two ordinary activities and a timer, including a genuine activity retry. +Independent queue processes retain the original admission parent and restore +their previous context. Each callback attempt has its own identity and span; +replaying the completed local callback adds no attempt span. The same recipe +covers tracing disabled, a legacy admission, and workers disabled after a +traced admission. It preserves the complete retry snapshots, original +deadlines and history prefixes, with zero repairs. + +The local callback precedes the parallel group. The existing `all()` API does +not admit local-activity descriptors as group members. Simultaneous-worker +load and large-fan-out overhead remain separate qualification gates. + +### Outcome links (draft) + +The draft adds a nullable, internal `workflow_tasks.observability_context` +column. A recording task can retain its actual outcome span context alongside +its existing task update. Entries bind the physical history event ID, sequence +and type to the original task, run, namespace and activity attempt or timer. +They carry no baggage or business result, are hidden from task JSON, and grant +no completion, retry or lease authority. The business history is unchanged. + +Local callbacks retain their actual span context until the hosting task records +the outcome. Ordinary activity and timer scopes capture their context before +outcome SQL. This uses the official W3C propagator directly, without invoking +application propagation hooks inside an execution transaction. Rollback removes +pending entries; duplicate completion cannot replace the first stored context. +Missing legacy columns and invalid optional metadata supply no outcome links. + +Before an enabled workflow task executes, it can read the latest 32 eligible +physical outcomes and their scoped task metadata outside the execution +transaction. Consuming a matching activity result or fired timer adds a +`durable.link.type=task_outcome` link to that actual span. The workflow span's +original admission parent remains unchanged. Each task carrier is capped at +4 KiB and 32 complete entries; the existing processing-segment link cap also +applies. Older outcomes outside this window can have no link. Disabled workers +perform no additional outcome-metadata reads or writes. + +Parent processing also prefetches at most 32 original child links and 32 +physical completed/failed child outcomes. Consuming a child resolution can add +`durable.link.type=child_outcome` only when the original parent link, authored +sequence, namespace/run/task bindings and physical terminal event all match. +The child history must already be loaded by execution; adding the link performs +no observation SQL. A missing or mismatched optional observation does not change +child resolution. Root and child observations share the existing 32-link +processing-segment cap; either bounded read window can omit older outcomes. + +When a recording processing segment consumes a prior physical outcome without +usable matching context, it sets +`durable.links.outcome_context_unavailable=true`. This boolean does not count +missing spans or identify why context is unavailable. It does not apply to a +foreign run or to outcomes represented by the current task's active scope. +Its absence does not prove that a trace graph is complete. The separate +`durable.links.dropped` attribute still reports the processing-segment link cap. + +Cold SQLite controls check the actual contributing local activity, ordinary +activity and timer span IDs, including genuine retries and handled terminal +activity failures. Parent links cover completed and failed children, with +disabled/legacy modes and wrong-task/namespace metadata controls. Transaction +rollback, duplicate decisions and legacy migration are exercised separately. +Timeout/cancellation causality, concurrent/large fan-out, multi-backend +acceptance and overhead remain separate qualification gates. +The new column remains unreleased and is not adopted by the service wire or +native catalogs. + +Queue dispatch emits a short `durable.task.dispatch` producer span outside the +database transaction. It covers the queue dispatcher call. A returned call is +not proof that a worker claimed or completed the task. Poll mode and fake +execution do not emit queue producer spans. + +The queued job can carry a bounded trace context tied to its exact task ID. +Cold workflow, activity and timer consumers link the producer as +`durable.link.type=queue_dispatch`; their parent remains the original persisted +run context. Queue metadata carries no baggage, payload, lease or deadline +authority. Missing, malformed or mismatched metadata supplies no dispatch link. +Legacy serialized jobs remain readable, and disabled tracing leaves newly +created jobs' serialized form unchanged. The service protocol is unchanged. + +The existing bridge-authored child-retry policy is covered through both queued +child failure and bridge-recorded failure, followed by separate cold retry, +activity and parent workers. Child admission and retries preserve the original +carrier, retry policy and execution deadline. This does not add retry-policy +options to the PHP `Workflow::child` helper. + +Independent-process cancellation controls cover a waiting workflow, a cooperative +request followed by its cleanup activity, and immediate cancellation or +termination. Existing carrier metadata and deadlines remain unchanged. Cleanup +uses the original run's parent, and task scopes restore the worker's context. +An accepted direct cooperative cancellation request also captures its caller +in the proposed command column. Its first actual delivery links that caller +to the workflow-processing span. Duplicate requests keep the first carrier. +Cleanup activities and later replay keep their original parent and add no new +delivery link. Caller baggage never replaces the run's baggage. Internal +descendant requests, immediate cancellation and termination have no caller-link +qualification yet. + +Disabling tracing stops new context capture and span emission. These qualified +embedded transitions and child-retry paths still inherit valid stored admission +context while the optional propagation API remains installed. The current +baggage allowlist still applies. Missing or unreadable metadata produces no +inherited parent. Task execution also discards unreadable telemetry metadata +without failing business work or rewriting the stored carrier. Task span +wrappers skip their metadata read when emission is disabled. Transition +inheritance follows the preservation rule above. + +## Schedule triggers + +Embedded PHP schedules emit a short `durable.schedule.trigger` internal span +around the trigger operation. Its processor starts and ends outside the +runtime-owned schedule transaction. The schedule's occurrence, overlap, action +budget and atomic admission decisions are unchanged. An application that wraps +the call in its own transaction still owns that outer boundary. +Synchronous start listeners retain the workflow instance and type in their +correlation fields without starting another span under the schedule lock. + +The actual triggering caller is captured before the trigger span. An admitted +run persists that caller for later workers; its cold processing spans do not +become children of the short trigger span. Creating a schedule does not persist +the creator's trace for future runs. Manual and periodic triggers have separate +cold-worker controls, including disabled tracing and an unrelated worker parent. + +Span attributes include `schedule_id`, namespace and +`durable.schedule.trigger_kind`: `manual`, `scheduled`, `buffered` or `backfill`. +Returned identities, `durable.schedule.outcome` and `durable.schedule.reason` +are attached when available. Backfill's instance-only return does not invent a +run ID. A buffered receipt is not an admitted run or a completed workflow. + +An accepted buffered trigger keeps its bounded caller carrier alongside that +specific buffered action in the internal `observability_context` field. A full +buffer keeps the first entry and its caller. The public schedule detail omits +this optional field, and tracing disabled at admission stores no carrier. + +The later drain keeps its actual scheduler parent and adds a +`buffered_schedule` link to the buffered caller after the runtime-owned admission +transaction commits. A rolled-back admission retains the original entry and +adds no success link. Legacy entries and malformed metadata produce no link; +they retain the normal scheduling behavior. The caller's baggage is not imported +into the drain. Separate cold processes qualify traced, disabled, full-buffer, +legacy/malformed and rollback-then-success paths with unchanged run deadlines, +typed results and first-attempt tasks. +Both `buffer_one` and ordered `buffer_all` draining have cold-process controls; +successive entries link their own callers rather than reusing the first caller. + +Cold backfill controls cover traced and disabled admission, an already-fired +occurrence, rollback and a later successful admission of that same occurrence. +Independent queue workers retain the actual backfill caller as their parent. +The failed admission reports an error without a successful instance or run +identity. Duplicate and failed admissions preserve the original run, carrier, +history and deadlines. A later occurrence gets its own caller without changing +the earlier completed run. Disabled controls keep the same business outcomes +without capturing a carrier or emitting spans. + +These schedule controls add no schedule column or service wire field. The +internal buffered metadata has no stable or native adoption claim. Full tracing +acceptance, broader gates and overhead measurements remain unfinished. + +## Signal caller links + +This draft also proposes a nullable `workflow_commands.observability_context` +column. New accepted embedded signals and signal-with-start commands capture +their own bounded carrier before the admission transaction. Existing command +metadata, including null, stays unchanged. The column is separate from command +context, payloads, history and idempotency. It is hidden from ordinary model JSON. +Each captured run or command carrier is limited to 4 KiB. Links add no durable +history events. + +When the embedded executor applies a signal, its processing span links to that +command's captured caller. This includes signals received before the first task +runs and after a real wait. The run's original parent and baggage stay active. +Legacy admissions without a parent keep independent processing segments with +links to their new signal callers. Replaying an already applied signal adds no +new signal-application link. + +```mermaid +flowchart LR + A[Captured caller] -->|parent| D[Admission operation] + A -->|parent| W[Workflow processing segment] + A -->|parent| X[Activity attempt] + B[Independent signal caller] -.->|link on application| W + X -->|parent| H[Instrumented HTTP client] +``` + +Links are added during processing. They do not change an earlier head-sampling +decision and are not proof that a transaction committed. Up to 32 distinct +caller trace/span contexts are retained per processing segment. Repeated callers +share a link identified by the first applied command. Further additions are +dropped, with the count in `durable.links.dropped`. Application SDK limits may +be lower. The link state ends with its processing scope. + +This does not add service wire propagation, native catalog support or complete +fan-in tracing. + +## Update caller links + +New accepted embedded updates capture their independent caller in the same +proposed command column before the admission transaction. Idempotent duplicate +requests keep the first carrier. Rejected commands do not retain it. + +The processing span links to the captured caller immediately before invoking +the accepted handler. This records the processing attempt even when the handler +fails. It does not prove that a transaction committed or that the update +succeeded. The run's parent and baggage remain active. Later replay of a +recorded update adds no new update-application link, even though it can execute +the handler to reconstruct workflow state. + +Cold-process controls cover successful and failed updates, duplicate admission, +a rejected undeclared update, later signal/activity completion and legacy or +disabled tracing. They preserve canonical update results/failures, command +identity, typed values, original history and deadlines. The existing per-segment +link bounds also apply to updates. Service/native update propagation and wider +concurrent update cases remain unqualified. + +The run carrier is separate observability metadata. It is not command, history, +payload or lease authority. The proposed nullable run column has not been +adopted by the native Server. Other admission paths, broader trigger links, +runtime coverage and performance qualification remain unfinished. + +## Processing duration and durable waits + +Workflow, activity-attempt and `durable.timer.task` spans cover actual processing. +A timer span starts after its task is claimed and ends after that processing +attempt. No span remains open during the durable wait. Timer scope setup and +teardown happen outside the existing execution transaction. + +These optional span attributes describe separate observations, in seconds: + +| Attribute | Meaning | +| --- | --- | +| `durable.task.queue_wait_seconds` | Claimed task's `leased_at` minus its `available_at`. The scheduled delay before eligibility is excluded. | +| `durable.task.retry_backoff_seconds` | Exact backoff recorded on a queued activity retry. It is requested delay, not measured execution or total elapsed retry time. | +| `durable.timer.requested_wait_seconds` | Delay recorded on the claimed timer. | +| `durable.timer.lateness_seconds` | Timer task claim minus the original timer deadline. | + +The values come from the already-claimed snapshot and add no telemetry query. +Missing, unreadable, malformed or negative timing observations are omitted. +Second and fractional database precision are supported. Stored clock deltas can +reflect clock adjustments and queue/scheduler delays; they are not monotonic +latency or a new authority rule. Existing eligibility, deadlines, retry policy +and history remain authoritative. Timing attributes are not added to log +correlation fields or automatically used as metric labels. + +The current controls cover cold embedded timer workers and a real queued +activity failure/backoff/retry, including disabled emission and a legacy +admission. Broader backend/runtime, local-activity, timeout and exporter-failure +qualification for timer processing remains unfinished. No performance gain or +overhead acceptance is claimed by these functional checks. diff --git a/docs/workflow/embedded-live-execution.md b/docs/workflow/embedded-live-execution.md index d3c94ec77..12e950eb2 100644 --- a/docs/workflow/embedded-live-execution.md +++ b/docs/workflow/embedded-live-execution.md @@ -63,10 +63,11 @@ The initial retained profile requires declared scalar or array entry-parameter types. Untyped, union/intersection, object, mixed, callable and iterable parameters select cold replay, including when their current argument is null. -Tracing integration is still an explicit qualification gate: a retained Fiber -must never carry an ended processing span or a previous job's task context. -The tracing-enabled cold fallback and context-toggle controls must pass before -the integrated feature is released. +Enabling execution tracing selects ordinary cold replay and clears retained +frames. Each workflow task starts a fresh Fiber with its current task context; +no retained Fiber carries an ended processing span. Turning tracing off allows +new frames to be retained after their next committed task. A frame cannot be +published if tracing becomes enabled before publication. ## Bounds and recovery diff --git a/scripts/ci/validate-regression-corpus.py b/scripts/ci/validate-regression-corpus.py index 11173f87f..2d03a2a43 100644 --- a/scripts/ci/validate-regression-corpus.py +++ b/scripts/ci/validate-regression-corpus.py @@ -159,6 +159,8 @@ "tests/Feature/V2/V2EmbeddedReplayRegressionCorpusTest.php", "tests/Fixtures/V2/TestConstructorInjectionActivity.php", "tests/Fixtures/V2/TestConstructorInjectionWorkflow.php", + "tests/Fixtures/V2/TestTracingReplayActivity.php", + "tests/Fixtures/V2/TestTracingReplayWorkflow.php", "tests/Fixtures/V2/TestEntryArgumentsMismatchWorkflow.php", "tests/Fixtures/V2/TestPortableLocalActivityIdentityWorkflow.php", "tests/Fixtures/V2/TestParallelChildReplayWorkflow.php", diff --git a/src/Providers/WorkflowServiceProvider.php b/src/Providers/WorkflowServiceProvider.php index 35a9251ed..3322a7484 100644 --- a/src/Providers/WorkflowServiceProvider.php +++ b/src/Providers/WorkflowServiceProvider.php @@ -4,6 +4,7 @@ namespace Workflow\Providers; +use Illuminate\Database\Events\TransactionRolledBack; use Illuminate\Queue\Events\Looping; use Illuminate\Queue\QueueManager; use Illuminate\Support\Facades\Event; @@ -61,6 +62,7 @@ use Workflow\V2\Support\DefaultServiceControlPlane; use Workflow\V2\Support\DefaultWorkflowControlPlane; use Workflow\V2\Support\DefaultWorkflowTaskBridge; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\HistoryProjectionMaintenanceFallback; use Workflow\V2\Support\InMemoryTaskFairnessState; use Workflow\V2\Support\LongPollCacheValidator; @@ -202,6 +204,9 @@ public function boot(): void // Register long-poll wake signal observers $this->registerLongPollObservers(); + Event::listen(TransactionRolledBack::class, static function (TransactionRolledBack $event): void { + ExecutionTracing::discardTaskOutcomes($event->connectionName); + }); // Validate cache backend for multi-node deployments $this->validateCacheBackend(); diff --git a/src/V2/Jobs/RunActivityTask.php b/src/V2/Jobs/RunActivityTask.php index b613ead92..970d99966 100644 --- a/src/V2/Jobs/RunActivityTask.php +++ b/src/V2/Jobs/RunActivityTask.php @@ -17,8 +17,11 @@ use Workflow\V2\Support\ActivityTaskClaim; use Workflow\V2\Support\ActivityTaskClaimer; use Workflow\V2\Support\EntryMethod; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\RuntimeObjectFactory; use Workflow\V2\Support\TaskDispatcher; +use Workflow\V2\Support\TaskTraceTimings; +use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\TypeRegistry; use Workflow\V2\Support\WorkerCompatibilityFleet; use Workflow\V2\Testing\ActivityFakeContext; @@ -33,6 +36,9 @@ final class RunActivityTask implements ShouldQueue public int $tries = 1; + // Mixed so malformed telemetry cannot prevent an older job from loading. + public mixed $dispatchObservabilityContext = null; + public function __construct( public readonly string $taskId, ) { @@ -63,6 +69,38 @@ public function handle(): void return; } + if (! ExecutionTracing::enabled()) { + $this->executeClaim($claim); + + return; + } + + $allowlist = ExecutionTracing::baggageAllowlist(); + $storedContext = TraceCarrier::fromRun($claim->run, $diagnostic); + ExecutionTracing::activity( + [ + 'workflow_instance_id' => $claim->run->workflow_instance_id, + 'workflow_run_id' => $claim->run->id, + 'workflow_type' => $claim->run->workflow_type, + 'namespace' => $claim->run->namespace, + 'task_id' => $claim->task->id, + 'activity_id' => $claim->execution->id, + 'activity_type' => $claim->execution->activity_type, + 'activity_attempt_id' => $claim->attemptId(), + 'activity_attempt_number' => $claim->attemptNumber(), + 'durable.trace_context.disposition' => $diagnostic, + ...TaskTraceTimings::forTask($claim->task), + ], + $storedContext, + fn () => $this->executeClaim($claim), + $allowlist, + true, + $this->dispatchObservabilityContext, + ); + } + + private function executeClaim(ActivityTaskClaim $claim): void + { $attemptId = $claim->attemptId(); $attemptCount = $claim->attemptNumber(); @@ -107,6 +145,9 @@ public function handle(): void } $maxAttempts = ActivityRetryPolicy::maxAttempts($execution, $activity); + if ($throwable !== null && ExecutionTracing::enabled()) { + ExecutionTracing::recordFailure($throwable); + } $backoffSeconds = ActivityRetryPolicy::backoffSeconds($execution, $activity, $attemptCount); $outcome = ActivityOutcomeRecorder::record( diff --git a/src/V2/Jobs/RunTimerTask.php b/src/V2/Jobs/RunTimerTask.php index 0e7be66ce..f62e3da50 100644 --- a/src/V2/Jobs/RunTimerTask.php +++ b/src/V2/Jobs/RunTimerTask.php @@ -19,12 +19,15 @@ use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\Models\WorkflowTimer; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ParallelChildGroup; use Workflow\V2\Support\SqliteTransaction; use Workflow\V2\Support\TaskBackendCapabilities; use Workflow\V2\Support\TaskCompatibility; use Workflow\V2\Support\TaskDispatcher; +use Workflow\V2\Support\TaskTraceTimings; use Workflow\V2\Support\TimerRecovery; +use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\WorkerCompatibilityFleet; use Workflow\V2\Support\WorkflowTaskLease; use Workflow\V2\Support\WorkflowTaskPayload; @@ -66,6 +69,9 @@ final class RunTimerTask implements ShouldQueue public int $tries = 5; + // Mixed so malformed telemetry cannot prevent an older job from loading. + public mixed $dispatchObservabilityContext = null; + public function __construct( public readonly string $taskId, ) { @@ -79,7 +85,7 @@ public function handle(): void is_string($this->queue ?? null) ? $this->queue : null, ); - [$timerId, $releaseIn] = $this->claimTask(); + [$claimedTask, $releaseIn] = $this->claimTask(); if ($releaseIn !== null) { // Relay capped/early wakeups without spending the queue job's failure budget. @@ -92,10 +98,42 @@ public function handle(): void return; } - if ($timerId === null) { + if ($claimedTask === null) { return; } + /** @var WorkflowTimer $timer */ + $timer = $claimedTask->getRelation('timer'); + if (! ExecutionTracing::enabled()) { + $this->executeClaim($timer->id); + + return; + } + /** @var WorkflowRun $run */ + $run = $claimedTask->getRelation('run'); + $allowlist = ExecutionTracing::baggageAllowlist(); + $storedContext = TraceCarrier::fromRun($run, $diagnostic); + ExecutionTracing::timer( + [ + 'workflow_instance_id' => $run->workflow_instance_id, + 'workflow_run_id' => $run->id, + 'workflow_type' => $run->workflow_type, + 'namespace' => $run->namespace, + 'task_id' => $claimedTask->id, + 'timer_id' => $timer->id, + 'durable.trace_context.disposition' => $diagnostic, + ...TaskTraceTimings::forTask($claimedTask, $timer), + ], + $storedContext, + fn () => $this->executeClaim($timer->id), + $allowlist, + true, + $this->dispatchObservabilityContext, + ); + } + + private function executeClaim(string $timerId): void + { $resumeTask = SqliteTransaction::forTask($this->taskId, function () use ($timerId): ?WorkflowTask { /** @var WorkflowTask $task */ $task = WorkflowTask::query() @@ -124,45 +162,7 @@ public function handle(): void return null; } - $cancelledInHistory = $run->historyEvents->contains(static fn (WorkflowHistoryEvent $event): bool => - $event->event_type === HistoryEventType::TimerCancelled - && ($event->payload['timer_id'] ?? null) === $timerId); - $firedInHistory = $run->historyEvents->contains(static fn (WorkflowHistoryEvent $event): bool => - $event->event_type === HistoryEventType::TimerFired - && ($event->payload['timer_id'] ?? null) === $timerId); - if ( - in_array($run->status, [RunStatus::Cancelled, RunStatus::Terminated], true) - || $timer->status === TimerStatus::Cancelled - || $cancelledInHistory - ) { - $task->forceFill([ - 'status' => $task->status === TaskStatus::Cancelled || $cancelledInHistory - ? TaskStatus::Cancelled : TaskStatus::Completed, - 'lease_owner' => null, - 'lease_expires_at' => null, - ])->save(); - - if ($timer->status !== TimerStatus::Cancelled) { - $timer->forceFill([ - 'status' => TimerStatus::Cancelled, - ])->save(); - } - - $this->projectRun($run, self::PROJECTION_RUN_RELATIONS); - - return null; - } - - if ($firedInHistory) { - $task->forceFill([ - 'status' => TaskStatus::Completed, - 'lease_owner' => null, - 'lease_expires_at' => null, - ])->save(); - $timer->forceFill([ - 'status' => TimerStatus::Fired, - ])->save(); - $this->projectRun($run, self::PROJECTION_RUN_RELATIONS); + if ($this->finishClosedTimer($run, $task, $timer, $timerId)) { return null; } @@ -170,29 +170,7 @@ public function handle(): void 'status' => TimerStatus::Fired, 'fired_at' => now(), ])->save(); - $conditionWaitId = is_string($task->payload['condition_wait_id'] ?? null) - ? $task->payload['condition_wait_id'] - : null; - $conditionWaitOccurrenceId = is_string($task->payload['condition_wait_occurrence_id'] ?? null) - ? $task->payload['condition_wait_occurrence_id'] - : null; - $conditionKey = is_string($task->payload['condition_key'] ?? null) - ? $task->payload['condition_key'] - : null; - $conditionDefinitionFingerprint = is_string($task->payload['condition_definition_fingerprint'] ?? null) - ? $task->payload['condition_definition_fingerprint'] - : null; - $signalWaitId = is_string($task->payload['signal_wait_id'] ?? null) - ? $task->payload['signal_wait_id'] - : null; - $signalName = is_string($task->payload['signal_name'] ?? null) - ? $task->payload['signal_name'] - : null; - $timerKind = match (true) { - $conditionWaitId !== null => 'condition_timeout', - $signalWaitId !== null => 'signal_timeout', - default => null, - }; + $waitMetadata = self::timerWaitMetadata($task); $parallelMetadataPath = ParallelChildGroup::metadataPathFromPayload($task->payload); $parallelMetadata = ParallelChildGroup::payloadForPath($parallelMetadataPath); @@ -202,13 +180,7 @@ public function handle(): void 'delay_seconds' => $timer->delay_seconds, 'fire_at' => $timer->fire_at?->toJSON(), 'fired_at' => $timer->fired_at?->toJSON(), - 'timer_kind' => $timerKind, - 'condition_wait_id' => $conditionWaitId, - 'condition_wait_occurrence_id' => $conditionWaitOccurrenceId, - 'condition_key' => $conditionKey, - 'condition_definition_fingerprint' => $conditionDefinitionFingerprint, - 'signal_wait_id' => $signalWaitId, - 'signal_name' => $signalName, + ...$waitMetadata, ...$parallelMetadata, ], static fn (mixed $value): bool => $value !== null), $task); @@ -218,8 +190,8 @@ public function handle(): void ])->save(); $operationKind = match (true) { - $conditionWaitId !== null => 'condition', - $signalWaitId !== null => 'signal', + $waitMetadata['condition_wait_id'] !== null => 'condition', + $waitMetadata['signal_wait_id'] !== null => 'signal', default => 'timer', }; if ($parallelMetadataPath !== []) { @@ -263,8 +235,101 @@ public function handle(): void } } + private function finishClosedTimer( + WorkflowRun $run, + WorkflowTask $task, + WorkflowTimer $timer, + string $timerId, + ): bool { + $cancelledInHistory = $run->historyEvents->contains(static fn (WorkflowHistoryEvent $event): bool => + $event->event_type === HistoryEventType::TimerCancelled + && ($event->payload['timer_id'] ?? null) === $timerId); + $firedInHistory = $run->historyEvents->contains(static fn (WorkflowHistoryEvent $event): bool => + $event->event_type === HistoryEventType::TimerFired + && ($event->payload['timer_id'] ?? null) === $timerId); + if ( + in_array($run->status, [RunStatus::Cancelled, RunStatus::Terminated], true) + || $timer->status === TimerStatus::Cancelled + || $cancelledInHistory + ) { + $task->forceFill([ + 'status' => $task->status === TaskStatus::Cancelled || $cancelledInHistory + ? TaskStatus::Cancelled : TaskStatus::Completed, + 'lease_owner' => null, + 'lease_expires_at' => null, + ])->save(); + + if ($timer->status !== TimerStatus::Cancelled) { + $timer->forceFill([ + 'status' => TimerStatus::Cancelled, + ])->save(); + } + + $this->projectRun($run, self::PROJECTION_RUN_RELATIONS); + + return true; + } + + if ($firedInHistory) { + $task->forceFill([ + 'status' => TaskStatus::Completed, + 'lease_owner' => null, + 'lease_expires_at' => null, + ])->save(); + $timer->forceFill([ + 'status' => TimerStatus::Fired, + ])->save(); + $this->projectRun($run, self::PROJECTION_RUN_RELATIONS); + + return true; + } + + return false; + } + + /** + * @return array{timer_kind: string|null, condition_wait_id: string|null, + * condition_wait_occurrence_id: string|null, condition_key: string|null, + * condition_definition_fingerprint: string|null, signal_wait_id: string|null, signal_name: string|null} + */ + private static function timerWaitMetadata(WorkflowTask $task): array + { + $conditionWaitId = is_string($task->payload['condition_wait_id'] ?? null) + ? $task->payload['condition_wait_id'] + : null; + $conditionWaitOccurrenceId = is_string($task->payload['condition_wait_occurrence_id'] ?? null) + ? $task->payload['condition_wait_occurrence_id'] + : null; + $conditionKey = is_string($task->payload['condition_key'] ?? null) + ? $task->payload['condition_key'] + : null; + $conditionDefinitionFingerprint = is_string($task->payload['condition_definition_fingerprint'] ?? null) + ? $task->payload['condition_definition_fingerprint'] + : null; + $signalWaitId = is_string($task->payload['signal_wait_id'] ?? null) + ? $task->payload['signal_wait_id'] + : null; + $signalName = is_string($task->payload['signal_name'] ?? null) + ? $task->payload['signal_name'] + : null; + + return [ + 'timer_kind' => match (true) { + $conditionWaitId !== null => 'condition_timeout', + $signalWaitId !== null => 'signal_timeout', + default => null, + }, + 'condition_wait_id' => $conditionWaitId, + 'condition_wait_occurrence_id' => $conditionWaitOccurrenceId, + 'condition_key' => $conditionKey, + 'condition_definition_fingerprint' => $conditionDefinitionFingerprint, + 'signal_wait_id' => $signalWaitId, + 'signal_name' => $signalName, + ]; + } + /** - * @return array{0: ?string, 1: ?int} + * @return array{0: WorkflowTask|null, 1: int|null} */ private function claimTask(): array { @@ -334,7 +399,7 @@ private function claimTask(): array $this->projectRun($run, self::PROJECTION_RUN_RELATIONS); - return [$timerId, null]; + return [$task->setRelation('run', $run)->setRelation('timer', $timer), null]; }); } diff --git a/src/V2/Jobs/RunWorkflowTask.php b/src/V2/Jobs/RunWorkflowTask.php index 37217ab3a..60deefecb 100644 --- a/src/V2/Jobs/RunWorkflowTask.php +++ b/src/V2/Jobs/RunWorkflowTask.php @@ -16,12 +16,16 @@ use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\Support\ConfiguredV2Models; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ServiceCallWorkflowDelivery; use Workflow\V2\Support\SqliteTransaction; use Workflow\V2\Support\StickyExecution; use Workflow\V2\Support\TaskBackendCapabilities; use Workflow\V2\Support\TaskCompatibility; use Workflow\V2\Support\TaskDispatcher; +use Workflow\V2\Support\TaskOutcomeCarrier; +use Workflow\V2\Support\TaskTraceTimings; +use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\WorkerCompatibilityFleet; use Workflow\V2\Support\WorkflowContinuationCache; use Workflow\V2\Support\WorkflowExecutor; @@ -36,6 +40,9 @@ final class RunWorkflowTask implements ShouldQueue public int $tries = 1; + // Mixed so malformed telemetry cannot prevent an older job from loading. + public mixed $dispatchObservabilityContext = null; + public function __construct( public readonly string $taskId, ) { @@ -49,7 +56,7 @@ public function handle(WorkflowExecutor $executor): void is_string($this->queue ?? null) ? $this->queue : null, ); - [$claimed, $deferredTask, $claimedAttempt] = $this->claimTask(); + [$task, $deferredTask, $claimedAttempt] = $this->claimTask(); if ($deferredTask instanceof WorkflowTask) { TaskDispatcher::dispatch($deferredTask); @@ -57,10 +64,42 @@ public function handle(WorkflowExecutor $executor): void return; } - if (! $claimed) { + if ($task === null) { return; } + if (! ExecutionTracing::enabled()) { + $this->executeClaim($executor, $claimedAttempt); + + return; + } + + /** @var WorkflowRun $run */ + $run = $task->getRelation('run'); + $allowlist = ExecutionTracing::baggageAllowlist(); + $storedContext = TraceCarrier::fromRun($run, $diagnostic); + // Activate and end the segment outside the execution transaction. + ExecutionTracing::workflow( + [ + 'workflow_instance_id' => $run->workflow_instance_id, + 'workflow_run_id' => $run->id, + 'workflow_type' => $run->workflow_type, + 'namespace' => $run->namespace, + 'task_id' => $task->id, + 'durable.trace_context.disposition' => $diagnostic, + ...TaskTraceTimings::forTask($task), + ], + $storedContext, + fn () => $this->executeClaim($executor, $claimedAttempt), + $allowlist, + true, + $this->dispatchObservabilityContext, + static fn (): array => TaskOutcomeCarrier::forRun($run, $task), + ); + } + + private function executeClaim(WorkflowExecutor $executor, int $claimedAttempt): void + { $nextTask = null; $continuation = config('workflows.v2.live_execution.workflows', []) === [] ? null : app(WorkflowContinuationCache::class)->session($this->job, $claimedAttempt); @@ -81,16 +120,7 @@ public function handle(WorkflowExecutor $executor): void ->findOrFail($task->workflow_run_id); if (in_array($run->status->value, ['completed', 'failed', 'cancelled', 'terminated'], true)) { - $task->forceFill([ - 'status' => $task->status === TaskStatus::Cancelled ? TaskStatus::Cancelled : ( - $run->status->value === 'failed' ? TaskStatus::Failed : TaskStatus::Completed - ), - 'lease_expires_at' => null, - ])->save(); - - $this->projectRun( - $run->fresh(['instance', 'tasks', 'activityExecutions', 'timers', 'failures']) - ); + $this->settleTerminalTask($task, $run); return null; } @@ -138,8 +168,20 @@ public function handle(WorkflowExecutor $executor): void } } + private function settleTerminalTask(WorkflowTask $task, WorkflowRun $run): void + { + $task->forceFill([ + 'status' => $task->status === TaskStatus::Cancelled ? TaskStatus::Cancelled : ( + $run->status->value === 'failed' ? TaskStatus::Failed : TaskStatus::Completed + ), + 'lease_expires_at' => null, + ])->save(); + + $this->projectRun($run->fresh(['instance', 'tasks', 'activityExecutions', 'timers', 'failures'])); + } + /** - * @return array{0: bool, 1: WorkflowTask|null, 2: int} + * @return array{0: WorkflowTask|null, 1: WorkflowTask|null, 2: int} */ private function claimTask(): array { @@ -150,11 +192,11 @@ private function claimTask(): array ->find($this->taskId); if ($task === null || $task->task_type !== TaskType::Workflow || $task->status !== TaskStatus::Ready) { - return [false, null, 0]; + return [null, null, 0]; } if ($task->available_at !== null && $task->available_at->isFuture()) { - return [false, $task, 0]; + return [null, $task, 0]; } /** @var WorkflowRun $run */ @@ -165,13 +207,13 @@ private function claimTask(): array if (TaskBackendCapabilities::recordClaimFailureIfUnsupported($task) !== null) { $this->projectRun($run->fresh(['instance', 'tasks', 'activityExecutions', 'failures'])); - return [false, null, 0]; + return [null, null, 0]; } if (! TaskCompatibility::supported($task, $run)) { $this->projectRun($run->fresh(['instance', 'tasks', 'activityExecutions', 'failures'])); - return [false, null, 0]; + return [null, null, 0]; } // The logical task ID is reused as owner; the attempt fences this claim. @@ -191,7 +233,7 @@ private function claimTask(): array ServiceCallWorkflowDelivery::recordForClaim($task, $run); $this->projectRun($run->fresh(['instance', 'tasks', 'activityExecutions', 'failures'])); - return [true, null, $claimedAttempt]; + return [$task->setRelation('run', $run), null, $claimedAttempt]; }); } diff --git a/src/V2/Models/WorkflowCommand.php b/src/V2/Models/WorkflowCommand.php index e6905ee42..5d7064571 100644 --- a/src/V2/Models/WorkflowCommand.php +++ b/src/V2/Models/WorkflowCommand.php @@ -36,6 +36,8 @@ class WorkflowCommand extends Model protected $keyType = 'string'; + protected $hidden = ['observability_context']; + protected $dateFormat = 'Y-m-d H:i:s.u'; protected $casts = [ @@ -45,6 +47,7 @@ class WorkflowCommand extends Model 'command_sequence' => 'integer', 'message_sequence' => 'integer', 'context' => 'array', + 'observability_context' => 'array', 'accepted_at' => 'datetime', 'applied_at' => 'datetime', 'rejected_at' => 'datetime', diff --git a/src/V2/Models/WorkflowHistoryEvent.php b/src/V2/Models/WorkflowHistoryEvent.php index 9ff8018ab..85788867d 100644 --- a/src/V2/Models/WorkflowHistoryEvent.php +++ b/src/V2/Models/WorkflowHistoryEvent.php @@ -12,6 +12,7 @@ use Workflow\Traits\ResolvesStorageConnection; use Workflow\V2\Enums\HistoryEventType; use Workflow\V2\Support\ConfiguredV2Models; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ExternalPayloads; use Workflow\V2\Support\HistoryEventPayloadContract; use Workflow\V2\Support\HistoryPayloadJson; @@ -128,6 +129,7 @@ public static function record( 'workflow_command_id' => $commandId, 'recorded_at' => $now, ]); + ExecutionTracing::observeOutcome($lockedRun, $taskModel, $event); $lockedRun->forceFill([ 'last_history_sequence' => $sequence, diff --git a/src/V2/Models/WorkflowRun.php b/src/V2/Models/WorkflowRun.php index c36b5f71e..7862e4a56 100644 --- a/src/V2/Models/WorkflowRun.php +++ b/src/V2/Models/WorkflowRun.php @@ -37,6 +37,7 @@ class WorkflowRun extends Model protected $casts = [ 'status' => RunStatus::class, + 'observability_context' => 'array', 'visibility_labels' => 'array', 'priority' => 'integer', 'fairness_weight' => 'integer', diff --git a/src/V2/Models/WorkflowSchedule.php b/src/V2/Models/WorkflowSchedule.php index 26695a690..dee260241 100644 --- a/src/V2/Models/WorkflowSchedule.php +++ b/src/V2/Models/WorkflowSchedule.php @@ -319,7 +319,10 @@ public static function normalizeActionTimeouts(array $action): array // ── Buffer queue ────────────────────────────────────────────────── - public function bufferAction(?DateTimeInterface $occurrenceTime = null): void + /** + * @param array{schema: string, version: int, carrier: array}|null $observabilityContext + */ + public function bufferAction(?DateTimeInterface $occurrenceTime = null, ?array $observabilityContext = null): void { $buffer = $this->buffered_actions ?? []; @@ -331,6 +334,9 @@ public function bufferAction(?DateTimeInterface $occurrenceTime = null): void if ($occurrenceTime !== null) { $action['occurrence_time'] = $occurrenceTime->format('Y-m-d\TH:i:s.uP'); } + if ($observabilityContext !== null) { + $action['observability_context'] = $observabilityContext; + } $buffer[] = $action; $this->buffered_actions = array_values($buffer); @@ -450,6 +456,17 @@ public function toListItem(): array */ public function toDetail(): array { + $bufferedActions = $this->buffered_actions ?? []; + if (is_array($bufferedActions)) { + $bufferedActions = array_map( + static fn (mixed $action): mixed => is_array($action) + ? array_diff_key($action, [ + 'observability_context' => true, + ]) : $action, + $bufferedActions + ); + } + return [ 'schedule_id' => $this->schedule_id, 'spec' => $this->spec, @@ -466,7 +483,7 @@ public function toDetail(): array 'fires_count' => (int) $this->fires_count, 'failures_count' => (int) $this->failures_count, 'recent_actions' => $this->recent_actions ?? [], - 'buffered_actions' => $this->buffered_actions ?? [], + 'buffered_actions' => $bufferedActions, ], 'memo' => $this->memo, 'search_attributes' => $this->search_attributes, diff --git a/src/V2/Models/WorkflowTask.php b/src/V2/Models/WorkflowTask.php index 6e44fa561..097ebe513 100644 --- a/src/V2/Models/WorkflowTask.php +++ b/src/V2/Models/WorkflowTask.php @@ -24,13 +24,14 @@ class WorkflowTask extends Model protected $guarded = []; - protected $hidden = ['queue_delivery_reference']; + protected $hidden = ['observability_context', 'queue_delivery_reference']; protected $keyType = 'string'; protected $dateFormat = 'Y-m-d H:i:s.u'; protected $casts = [ + 'observability_context' => 'array', 'task_type' => TaskType::class, 'status' => TaskStatus::class, 'payload' => 'array', diff --git a/src/V2/Observers/WorkflowTaskObserver.php b/src/V2/Observers/WorkflowTaskObserver.php index 81abed250..85d90a14f 100644 --- a/src/V2/Observers/WorkflowTaskObserver.php +++ b/src/V2/Observers/WorkflowTaskObserver.php @@ -11,6 +11,7 @@ use Workflow\V2\Models\WorkflowTask; use Workflow\V2\Support\CacheLongPollWakeStore; use Workflow\V2\Support\ConfiguredV2Models; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\StickyExecution; /** @@ -27,6 +28,11 @@ public function __construct( ) { } + public function updating(WorkflowTask $task): void + { + ExecutionTracing::persistTaskOutcomes($task); + } + public function creating(WorkflowTask $task): void { if ($task->task_type !== TaskType::Workflow && $task->task_type !== TaskType::Workflow->value) { diff --git a/src/V2/Support/ChildRunHistory.php b/src/V2/Support/ChildRunHistory.php index dc509a1bf..953840520 100644 --- a/src/V2/Support/ChildRunHistory.php +++ b/src/V2/Support/ChildRunHistory.php @@ -582,6 +582,7 @@ public static function outputForResolution( ?WorkflowRun $childRun = null, ): mixed { $childRun ??= self::loadRun(self::stringValue($resolutionEvent->payload['child_workflow_run_id'] ?? null)); + ExecutionTracing::linkChildOutcome($resolutionEvent, $childRun); $payload = $resolutionEvent->payload['output'] ?? null; if ($payload === null && $childRun !== null) { @@ -610,6 +611,7 @@ public static function exceptionForResolution( WorkflowHistoryEvent $resolutionEvent, ?WorkflowRun $childRun = null, ): Throwable { + ExecutionTracing::linkChildOutcome($resolutionEvent, $childRun); $payload = self::exceptionPayload( is_array($resolutionEvent->payload['exception'] ?? null) ? $resolutionEvent->payload['exception'] : null, $childRun, diff --git a/src/V2/Support/DefaultWorkflowTaskBridge.php b/src/V2/Support/DefaultWorkflowTaskBridge.php index 68dccf2ba..69e3e6594 100644 --- a/src/V2/Support/DefaultWorkflowTaskBridge.php +++ b/src/V2/Support/DefaultWorkflowTaskBridge.php @@ -4249,6 +4249,7 @@ private function applyStartChildWorkflow( $childRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $childInstance->id, 'run_number' => 1, + 'observability_context' => TraceCarrier::fromRun($run), 'workflow_class' => $workflowType, 'workflow_type' => $workflowType, 'namespace' => $run->namespace, @@ -5264,6 +5265,7 @@ private function startChildRetryIfAvailable( $retryRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $failedChildRun->workflow_instance_id, 'run_number' => $nextRunNumber, + 'observability_context' => TraceCarrier::fromRun($failedChildRun), 'workflow_class' => $failedChildRun->workflow_class, 'workflow_type' => $failedChildRun->workflow_type, 'namespace' => $failedChildRun->namespace, diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php new file mode 100644 index 000000000..7301fb613 --- /dev/null +++ b/src/V2/Support/ExecutionTracing.php @@ -0,0 +1,968 @@ +|null + */ + private static ?WeakMap $activeFibers = null; + + public static function enabled(): bool + { + try { + return config('workflows.tracing.enabled', false) === true; + } catch (Throwable) { + // Optional configuration must not interrupt admission or execution. + return false; + } + } + + /** + * @return list + */ + public static function baggageAllowlist(): array + { + try { + $allowlist = config('workflows.tracing.baggage_allowlist', []); + + return is_array($allowlist) + ? array_values(array_filter($allowlist, static fn (mixed $value): bool => is_string($value))) + : []; + } catch (Throwable) { + // Without a readable allowlist, propagate no baggage. + return []; + } + } + + public static function bind(callable $callback): callable + { + try { + if (self::current() === []) { + return $callback; + } + $context = Context::getCurrent(); + } catch (Throwable) { + return $callback; + } + + // Attach inside the new Fiber. Its scope must also unwind inside that Fiber. + return static function (mixed ...$arguments) use ($callback, $context): mixed { + $scope = null; + try { + $scope = $context->activate(); + self::enterFiberScope(); + } catch (Throwable) { + // Optional tracing setup must leave the original Fiber callback usable. + } + try { + return $callback(...$arguments); + } finally { + self::close($scope, null); + } + }; + } + + /** + * @param array $attributes + * @param list $baggageAllowlist + */ + public static function activity( + array $attributes, + mixed $persistedContext, + callable $operation, + array $baggageAllowlist = [], + bool $enabled = false, + mixed $dispatchContext = null, + ): mixed { + return self::segment( + 'durable.activity.attempt', + $attributes, + $persistedContext, + $operation, + $baggageAllowlist, + $enabled, + $dispatchContext, + ); + } + + /** + * @param array $attributes + * @param list $baggageAllowlist + */ + public static function localActivity( + array $attributes, + mixed $persistedContext, + callable $operation, + array $baggageAllowlist = [], + bool $enabled = false, + ): mixed { + return self::segment( + 'durable.local_activity.attempt', + $attributes, + $persistedContext, + $operation, + $baggageAllowlist, + $enabled, + ); + } + + /** + * @param array $attributes + * @param list $baggageAllowlist + */ + public static function workflow( + array $attributes, + mixed $persistedContext, + callable $operation, + array $baggageAllowlist = [], + bool $enabled = false, + mixed $dispatchContext = null, + ?callable $loadOutcomes = null, + ): mixed { + return self::segment( + 'durable.workflow.task', + $attributes, + $persistedContext, + $operation, + $baggageAllowlist, + $enabled, + $dispatchContext, + onActivated: static function () use ($loadOutcomes): void { + try { + $state = self::$linkKey === null ? null : Context::getCurrent()->get(self::$linkKey); + if ($loadOutcomes !== null && $state instanceof TraceLinkState && $state->isRecording()) { + $state->setOutcomes($loadOutcomes()); + } + } catch (Throwable) { + // Optional observation reads do not replace the original task. + } + }, + ); + } + + public static function observeOutcome(WorkflowRun $run, ?WorkflowTask $task, WorkflowHistoryEvent $event): void + { + try { + if ($task !== null) { + self::outcomeState()?->observe($run, $task, $event); + } + } catch (Throwable) { + // The recorded business event remains authoritative without telemetry. + } + } + + public static function persistTaskOutcomes(WorkflowTask $task): void + { + try { + self::outcomeState()?->apply($task); + } catch (Throwable) { + // Optional task metadata cannot replace the existing fenced write. + } + } + + public static function discardTaskOutcomes(mixed $connection): void + { + try { + if (is_string($connection)) { + self::outcomeState()?->discard($connection); + } + } catch (Throwable) { + // Rollback still belongs to the database transaction manager. + } + } + + public static function linkOutcome(?WorkflowRun $run, ?WorkflowHistoryEvent $event): void + { + try { + if ($run === null || $event === null || self::$linkKey === null) { + return; + } + $fields = self::current(); + if (($fields['workflow_run_id'] ?? null) !== $run->id || ($fields['namespace'] ?? null) !== $run->namespace + || $event->workflow_run_id !== $run->id) { + return; + } + $state = Context::getCurrent()->get(self::$linkKey); + if (! $state instanceof TraceLinkState || ! $state->isRecording()) { + return; + } + $entry = $state->outcome($event->id); + if ($entry === null || $entry['task_id'] !== $event->workflow_task_id + || ! TaskOutcomeCarrier::matchesEvent($entry, $event)) { + if ($event->workflow_task_id !== ($fields['task_id'] ?? null)) { + $state->markOutcomeUnavailable(); + } + return; + } + self::linkCarrier($state, $entry['context'], [ + 'durable.link.type' => 'task_outcome', + 'history_event_id' => $event->id, + 'event_type' => $event->event_type->value, + 'task_id' => $event->workflow_task_id, + ]); + } catch (Throwable) { + // A missing or malformed causal observation does not change replay. + } + } + + public static function linkChildOutcome(WorkflowHistoryEvent $resolution, ?WorkflowRun $child): void + { + try { + $state = self::recordingLinksForRun($resolution->workflow_run_id); + if ($state === null) { + return; + } + if ($child === null || ! $child->relationLoaded('historyEvents')) { + $state->markOutcomeUnavailable(); + return; + } + $entry = $state->outcome('child:' . $child->id); + $terminal = $child->getRelation('historyEvents') + ->filter( + static fn (WorkflowHistoryEvent $event): bool => in_array($event->event_type->value, [ + 'WorkflowCompleted', 'WorkflowFailed', 'WorkflowCancelled', 'WorkflowTerminated', + ], true), + )->sortByDesc('sequence') + ->first(); + if ($entry === null || ! $terminal instanceof WorkflowHistoryEvent + || ! self::childOutcomeMatches($entry, $resolution, $child, $terminal)) { + $state->markOutcomeUnavailable(); + return; + } + self::linkCarrier($state, $entry['context'], [ + 'durable.link.type' => 'child_outcome', + 'history_event_id' => $terminal->id, + 'event_type' => $terminal->event_type->value, + 'task_id' => $terminal->workflow_task_id, + 'child_workflow_run_id' => $child->id, + 'parent_resolution_event_id' => $resolution->id, + ]); + } catch (Throwable) { + // Child resolution and its original authority remain independent of tracing. + } + } + + /** + * @param array $attributes + * @param list $baggageAllowlist + */ + public static function timer( + array $attributes, + mixed $persistedContext, + callable $operation, + array $baggageAllowlist = [], + bool $enabled = false, + mixed $dispatchContext = null, + ): mixed { + return self::segment( + 'durable.timer.task', + $attributes, + $persistedContext, + $operation, + $baggageAllowlist, + $enabled, + $dispatchContext, + ); + } + + public static function dispatch( + WorkflowTask $task, + RunWorkflowTask|RunActivityTask|RunTimerTask $job, + callable $operation, + bool $enabled = false, + ): mixed { + if (! $enabled || ! class_exists(Globals::class)) { + return $operation(); + } + $attributes = [ + 'task_id' => $job->taskId, + ]; + try { + $attributes += [ + 'workflow_run_id' => $task->getRawOriginal('workflow_run_id'), + 'namespace' => $task->getRawOriginal('namespace'), + 'durable.task.type' => $task->getRawOriginal('task_type'), + ]; + } catch (Throwable) { + // Optional loaded identity cannot replace the queue operation. + } + + return self::segment( + 'durable.task.dispatch', + $attributes, + null, + $operation, + [], + true, + onActivated: static function () use ($job): void { + $job->dispatchObservabilityContext = TaskTraceCarrier::capture($job->taskId); + } + ); + } + + /** + * @return array + */ + public static function current(): array + { + if (self::$correlationKey === null || ! class_exists(Context::class)) { + return []; + } + + // The SDK's compatibility fallback to the main Fiber is not a durable binding. + $fiber = Fiber::getCurrent(); + if ($fiber !== null && ! isset(self::$activeFibers[$fiber])) { + return []; + } + + $fields = Context::getCurrent()->get(self::$correlationKey); + if (! is_array($fields)) { + return []; + } + // Application instrumentation may activate a child span inside this durable scope. + unset($fields['trace_id'], $fields['span_id']); + $spanContext = Span::getCurrent()->getContext(); + if ($spanContext->isValid()) { + $fields['trace_id'] = $spanContext->getTraceId(); + $fields['span_id'] = $spanContext->getSpanId(); + } + + return $fields; + } + + public static function admission( + WorkflowInstance $instance, + callable $operation, + bool $enabled = false, + ?string $diagnostic = null, + ): mixed { + if (! $enabled || ! class_exists(Globals::class)) { + return $operation(); + } + $attributes = [ + 'durable.trace_context.disposition' => $diagnostic, + ]; + try { + $attributes += [ + 'workflow_instance_id' => $instance->getRawOriginal('id'), + 'workflow_type' => $instance->getRawOriginal('workflow_type'), + 'namespace' => $instance->getRawOriginal('namespace'), + ]; + } catch (Throwable) { + // Optional identity reads do not replace admission. + } + if (TraceCarrier::hasPreparedAdmission()) { + return self::withAdmissionFields($attributes, $operation); + } + + return self::segment('durable.workflow.admission', $attributes, null, $operation, [], true); + } + + public static function scheduleAdmission(WorkflowSchedule $schedule, callable $operation, string $kind): mixed + { + if (! self::enabled() || ! class_exists(Globals::class) || TraceCarrier::hasPreparedAdmission()) { + return $operation(); + } + $attributes = []; + try { + $attributes = [ + 'schedule_id' => $schedule->getRawOriginal('schedule_id'), + 'namespace' => $schedule->getRawOriginal('namespace'), + 'durable.schedule.trigger_kind' => $kind, + ]; + } catch (Throwable) { + // Optional schedule identity does not replace its locked admission decision. + } + + return TraceCarrier::withPreparedAdmission( + static fn (): mixed => self::segment('durable.schedule.trigger', $attributes, null, $operation, [], true) + ); + } + + public static function recordFailure(Throwable $error): void + { + try { + if (self::current() === []) { + return; + } + Span::getCurrent()->setStatus(StatusCode::STATUS_ERROR) + ->setAttribute('error.type', $error::class); + } catch (Throwable) { + // Telemetry cannot replace the application's original exception. + } + } + + public static function recordCarrierDiagnostic(?string $diagnostic): void + { + if (! in_array($diagnostic, ['discarded', 'filtered'], true) || ! self::enabled()) { + return; + } + try { + $span = Span::getCurrent(); + if ($span->isRecording()) { + $span->setAttribute('durable.trace_context.disposition', $diagnostic); + } + } catch (Throwable) { + // Diagnostics cannot replace the business result or export independently. + } + } + + public static function linkSignal(WorkflowRun $run, WorkflowCommand $command): void + { + self::linkCommand($run, $command, CommandType::Signal); + } + + public static function linkUpdate(WorkflowRun $run, WorkflowCommand $command): void + { + self::linkCommand($run, $command, CommandType::Update); + } + + public static function linkBufferedSchedule(WorkflowSchedule $schedule, mixed $stored): void + { + if (! self::enabled()) { + return; + } + try { + $fields = self::current(); + if (self::$linkKey === null || ($fields['schedule_id'] ?? null) !== $schedule->getRawOriginal('schedule_id') + || ($fields['namespace'] ?? null) !== $schedule->getRawOriginal('namespace')) { + return; + } + $state = Context::getCurrent()->get(self::$linkKey); + if (! $state instanceof TraceLinkState || ! $state->isRecording()) { + return; + } + $carrier = TraceCarrier::normalize($stored, diagnostic: $diagnostic); + self::recordCarrierDiagnostic($diagnostic); + if ($carrier !== null) { + self::linkCarrier($state, $carrier, [ + 'durable.link.type' => 'buffered_schedule', + ]); + } + } catch (Throwable) { + // Optional causal metadata cannot replace a committed schedule decision. + } + } + + public static function linkCancellation(WorkflowRun $run): void + { + try { + // Execution already owns this loaded snapshot. Telemetry adds no database read. + if (! $run->relationLoaded('commands')) { + return; + } + $command = $run->commands->firstWhere('id', $run->cancellation_request_command_id); + if ($command instanceof WorkflowCommand) { + self::linkCommand($run, $command, CommandType::RequestCancellation); + } + } catch (Throwable) { + // Optional metadata cannot interrupt cancellation delivery. + } + } + + private static function recordingLinksForRun(string $runId): ?TraceLinkState + { + if (self::$linkKey === null || (self::current()['workflow_run_id'] ?? null) !== $runId) { + return null; + } + $state = Context::getCurrent()->get(self::$linkKey); + + return $state instanceof TraceLinkState && $state->isRecording() ? $state : null; + } + + private static function childOutcomeMatches( + array $entry, + WorkflowHistoryEvent $resolution, + WorkflowRun $child, + WorkflowHistoryEvent $terminal, + ): bool { + $expected = match ($resolution->event_type->value) { + 'ChildRunCompleted' => 'WorkflowCompleted', + 'ChildRunFailed' => 'WorkflowFailed', + default => null, + }; + $fields = self::current(); + + return $expected !== null && $terminal->event_type->value === $expected + && ($fields['workflow_run_id'] ?? null) === $resolution->workflow_run_id + && ($fields['workflow_run_id'] ?? null) === $entry['parent_run_id'] + && ($fields['namespace'] ?? null) === $entry['parent_namespace'] + && ($resolution->payload['workflow_link_id'] ?? null) === $entry['link_id'] + && ($resolution->payload['sequence'] ?? null) === $entry['parent_sequence'] + && ($resolution->payload['child_workflow_run_id'] ?? null) === $child->id + && ($resolution->payload['child_workflow_instance_id'] ?? null) === $child->workflow_instance_id + && $child->id === $entry['child_run_id'] && $terminal->workflow_run_id === $child->id + && $child->namespace === $entry['child_namespace'] + && $child->workflow_instance_id === $entry['child_instance_id'] + && $entry['task_id'] === $terminal->workflow_task_id + && TaskOutcomeCarrier::matchesEvent($entry, $terminal); + } + + private static function outcomeState(): ?TaskOutcomeTraceState + { + if (self::$outcomeKey === null || self::current() === []) { + return null; + } + $state = Context::getCurrent()->get(self::$outcomeKey); + + return $state instanceof TaskOutcomeTraceState ? $state : null; + } + + private static function linkCommand(WorkflowRun $run, WorkflowCommand $command, CommandType $type): void + { + try { + $fields = self::current(); + if (self::$linkKey === null || ($fields['workflow_run_id'] ?? null) !== $run->id + || ($fields['namespace'] ?? null) !== $run->namespace + || $command->workflow_run_id !== $run->id + || $command->workflow_instance_id !== $run->workflow_instance_id + || $command->command_type !== $type || $command->status !== CommandStatus::Accepted) { + return; + } + $state = Context::getCurrent()->get(self::$linkKey); + if (! $state instanceof TraceLinkState || ! $state->isRecording()) { + return; + } + $carrier = TraceCarrier::fromCommand($command, $diagnostic); + self::recordCarrierDiagnostic($diagnostic); + if ($carrier === null) { + return; + } + self::linkCarrier($state, $carrier, [ + 'workflow_command_id' => $command->id, + 'command_type' => $type->value, + ]); + } catch (Throwable) { + // Optional causal links cannot replace the command's durable decision. + } + } + + /** + * @param array{schema: string, version: int, carrier: array} $carrier + * @param array $attributes + */ + private static function linkCarrier(TraceLinkState $state, array $carrier, array $attributes): void + { + $context = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: Context::getRoot()); + $state->add(Span::fromContext($context)->getContext(), $attributes); + } + + /** + * @param array $attributes + */ + private static function withAdmissionFields(array $attributes, callable $operation): mixed + { + $scope = null; + try { + self::$correlationKey ??= Context::createKey('durable-workflow.correlation'); + $fields = array_replace(self::current(), self::fields($attributes)); + $scope = Context::getCurrent()->with(self::$correlationKey, $fields)->activate(); + self::enterFiberScope(); + } catch (Throwable) { + self::close($scope, null); + + return $operation(); + } + + try { + return $operation(); + } finally { + self::close($scope, null); + } + } + + /** + * @param array $attributes + * @param list $baggageAllowlist + */ + private static function segment( + string $name, + array $attributes, + mixed $persistedContext, + callable $operation, + array $baggageAllowlist, + bool $enabled, + mixed $dispatchContext = null, + ?callable $onActivated = null, + ): mixed { + if (! $enabled || ! class_exists(Globals::class)) { + return $operation(); + } + + $span = null; + $scope = null; + try { + $fields = self::fields($attributes); + $parent = self::parentContext($name, $persistedContext, $baggageAllowlist, $fields, $diagnostic); + $attributes['durable.trace_context.disposition'] ??= $diagnostic + ?? ($name === 'durable.schedule.trigger' ? TraceCarrier::preparedAdmissionDiagnostic() : null); + $spanAttributes = self::spanAttributes($name, $attributes, $fields); + $version = InstalledVersions::isInstalled(self::PACKAGE_NAME) + ? InstalledVersions::getPrettyVersion(self::PACKAGE_NAME) + : null; + $builder = Globals::tracerProvider()->getTracer(self::PACKAGE_NAME, $version) + ->spanBuilder($name) + ->setSpanKind(match ($name) { + 'durable.workflow.admission', 'durable.schedule.trigger', + 'durable.local_activity.attempt' => SpanKind::KIND_INTERNAL, + 'durable.task.dispatch' => SpanKind::KIND_PRODUCER, + default => SpanKind::KIND_CONSUMER, + }) + ->setParent($parent) + ->setAttributes($spanAttributes); + if ($dispatchContext !== null) { + $dispatch = TaskTraceCarrier::fromJob($dispatchContext, $fields['task_id'] ?? '', $dispatchDiagnostic); + if ($dispatchDiagnostic !== null) { + $builder->setAttribute('durable.queue_context.disposition', $dispatchDiagnostic); + } + if ($dispatch !== null) { + $linked = TraceContextPropagator::getInstance()->extract( + $dispatch['carrier'], + context: Context::getRoot() + ); + $builder->addLink(Span::fromContext($linked)->getContext(), [ + 'durable.link.type' => 'queue_dispatch', + 'task_id' => $fields['task_id'], + ]); + } + } + $span = $builder->startSpan(); + if ($span->getContext()->isValid()) { + $fields['trace_id'] = $span->getContext()->getTraceId(); + $fields['span_id'] = $span->getContext()->getSpanId(); + } + self::$correlationKey ??= Context::createKey('durable-workflow.correlation'); + $active = $span->storeInContext($parent) + ->with(self::$correlationKey, $fields); + $active = self::withOutcomeState($active, $name, $fields, $span); + if (in_array($name, ['durable.workflow.task', 'durable.schedule.trigger'], true)) { + self::$linkKey ??= Context::createKey('durable-workflow.trace-links'); + $active = $active->with(self::$linkKey, new TraceLinkState($span)); + } + $scope = $active->activate(); + self::enterFiberScope(); + self::rememberLocalOutcome($name, $fields, $span); + if ($onActivated !== null) { + $onActivated(); + } + } catch (Throwable) { + self::close($scope, $span); + + return $operation(); + } + + try { + $result = $operation(); + self::recordResult($span, $name, $result); + + return $result; + } catch (Throwable $error) { + self::recordFailure($error); + throw $error; + } finally { + self::close($scope, $span); + } + } + + /** + * @param array $fields + */ + private static function withOutcomeState( + ContextInterface $active, + string $name, + array $fields, + SpanInterface $span + ): ContextInterface { + if (! in_array($name, ['durable.workflow.task', 'durable.activity.attempt', 'durable.timer.task'], true)) { + return $active; + } + $context = TaskOutcomeCarrier::capture($span); + if ($context === null) { + return $active; + } + self::$outcomeKey ??= Context::createKey('durable-workflow.task-outcomes'); + + return $active->with(self::$outcomeKey, new TaskOutcomeTraceState($name, $fields, $context)); + } + + private static function rememberLocalOutcome(string $name, array $fields, SpanInterface $span): void + { + if ($name !== 'durable.local_activity.attempt') { + return; + } + $context = TaskOutcomeCarrier::capture($span); + if ($context !== null) { + self::outcomeState()?->rememberLocal($fields, $context); + } + } + + private static function parentContext( + string $name, + mixed $persistedContext, + array $baggageAllowlist, + array $fields, + ?string &$diagnostic, + ): ContextInterface { + // Local callbacks may share their hosting task. Other worker segments recover the stored parent. + $localParent = in_array($name, ['durable.workflow.admission', 'durable.schedule.trigger'], true) + || ($name === 'durable.task.dispatch' && self::current() !== []); + $hostingTask = $name === 'durable.local_activity.attempt' && self::matchesHostingTask($fields); + $parent = $localParent || $hostingTask ? Context::getCurrent() : Context::getRoot(); + $diagnostic = null; + $carrier = $hostingTask ? null : TraceCarrier::normalize($persistedContext, $baggageAllowlist, $diagnostic); + if ($carrier !== null) { + $parent = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: $parent); + $parent = BaggagePropagator::getInstance()->extract($carrier['carrier'], context: $parent); + } + + // Preserve application providers without inheriting unrelated worker context. + return Configurator::create() + ->withTracerProvider(Globals::tracerProvider()) + ->withMeterProvider(Globals::meterProvider()) + ->withLoggerProvider(Globals::loggerProvider()) + ->withPropagator(Globals::propagator()) + ->withResponsePropagator(Globals::responsePropagator()) + ->storeInContext($parent); + } + + /** + * @param array $fields + */ + private static function matchesHostingTask(array $fields): bool + { + $current = self::current(); + foreach (['workflow_instance_id', 'workflow_run_id', 'namespace', 'task_id'] as $key) { + if (! isset($fields[$key]) || ($current[$key] ?? null) !== $fields[$key]) { + return false; + } + } + + return true; + } + + /** + * @param array $attributes + * @param array $fields + * @return array + */ + private static function spanAttributes(string $name, array $attributes, array $fields): array + { + $spanAttributes = $fields; + if (in_array($attributes['durable.trace_context.disposition'] ?? null, ['discarded', 'filtered'], true)) { + $spanAttributes['durable.trace_context.disposition'] = $attributes['durable.trace_context.disposition']; + } + foreach (self::TIMINGS as $key) { + $value = $attributes[$key] ?? null; + if ((is_int($value) || is_float($value)) && is_finite((float) $value) && $value >= 0) { + $spanAttributes[$key] = $value; + } + } + if ($name === 'durable.task.dispatch' && in_array( + $attributes['durable.task.type'] ?? null, + ['workflow', 'activity', 'timer'], + true + )) { + $spanAttributes['durable.task.type'] = $attributes['durable.task.type']; + } + if ($name === 'durable.schedule.trigger' && in_array( + $attributes['durable.schedule.trigger_kind'] ?? null, + ['manual', 'scheduled', 'buffered', 'backfill'], + true + )) { + $spanAttributes['durable.schedule.trigger_kind'] = $attributes['durable.schedule.trigger_kind']; + } + + return $spanAttributes; + } + + private static function recordResult(?SpanInterface $span, string $name, mixed $result): void + { + if ($span === null) { + return; + } + try { + if ($name === 'durable.task.dispatch') { + $span->setAttribute('durable.dispatch.outcome', 'returned'); + } + if ($name === 'durable.workflow.admission' + && ($result instanceof StartResult || $result instanceof SignalWithStartResult)) { + self::recordAdmissionResult($span, $result); + } + if ($name === 'durable.schedule.trigger') { + self::recordScheduleResult($span, $result); + } + } catch (Throwable) { + // Optional receipt metadata cannot replace a returned business value. + } + } + + private static function recordScheduleResult(SpanInterface $span, mixed $result): void + { + $receipt = match (true) { + $result instanceof ScheduleTriggerResult => [ + 'instance_id' => $result->instanceId, + 'run_id' => $result->runId, + 'outcome' => $result->outcome, + 'reason' => $result->reason, + ], + is_array($result) => $result, + is_string($result) => [ + 'instance_id' => $result, + 'outcome' => 'triggered', + ], + default => [ + 'outcome' => 'no_run', + ], + }; + $attributes = self::fields([ + 'workflow_instance_id' => $receipt['instance_id'] ?? null, + 'workflow_run_id' => $receipt['run_id'] ?? null, + ]); + foreach (['outcome', 'reason'] as $key) { + $value = $receipt[$key] ?? null; + if (is_string($value) && $value !== '' && strlen($value) <= 256 + && ! preg_match(self::CONTROL_CHARACTERS, $value)) { + $attributes['durable.schedule.' . $key] = $value; + } + } + $span->setAttributes($attributes); + } + + /** + * @param array $attributes + * @return array + */ + private static function fields(array $attributes): array + { + $fields = []; + foreach (self::FIELDS as $key) { + $value = $attributes[$key] ?? null; + if (($key === 'activity_attempt_number' && is_int($value) && $value > 0) + || ($key !== 'activity_attempt_number' && is_string($value) && $value !== '' && strlen($value) <= 512 + && ! preg_match(self::CONTROL_CHARACTERS, $value))) { + $fields[$key] = $value; + } + } + + return $fields; + } + + private static function recordAdmissionResult(SpanInterface $span, CommandResult $result): void + { + $attributes = self::fields([ + 'workflow_instance_id' => $result->instanceId(), + 'workflow_run_id' => $result->runId(), + 'workflow_type' => $result->workflowType(), + 'workflow_command_id' => $result->commandId(), + ]); + $decisions = [ + 'durable.command.type' => $result->type(), + 'durable.command.status' => $result->status(), + 'durable.command.outcome' => $result->outcome(), + 'durable.command.rejection_reason' => $result->rejectionReason(), + ]; + if ($result instanceof SignalWithStartResult) { + $decisions += [ + 'durable.start.command_id' => $result->startCommandId(), + 'durable.start.status' => $result->startStatus(), + 'durable.start.outcome' => $result->startOutcome(), + ]; + } + foreach ($decisions as $key => $value) { + if (is_string($value) && $value !== '' && strlen($value) <= 256 + && ! preg_match(self::CONTROL_CHARACTERS, $value)) { + $attributes[$key] = $value; + } + } + $span->setAttributes($attributes); + } + + private static function close(?ScopeInterface $scope, ?SpanInterface $span): void + { + try { + $scope?->detach(); + } catch (Throwable) { + // A telemetry scope failure cannot replace the business result or exception. + } + $fiber = Fiber::getCurrent(); + if ($scope !== null && $fiber !== null && isset(self::$activeFibers[$fiber]) + && --self::$activeFibers[$fiber] === 0) { + unset(self::$activeFibers[$fiber]); + } + try { + // The application owns buffering and provider shutdown. Never flush per task. + $span?->end(); + } catch (Throwable) { + // Exporter errors cannot replace an already determined business outcome. + } + } + + private static function enterFiberScope(): void + { + $fiber = Fiber::getCurrent(); + if ($fiber !== null) { + self::$activeFibers ??= new WeakMap(); + self::$activeFibers[$fiber] = (self::$activeFibers[$fiber] ?? 0) + 1; + } + } +} diff --git a/src/V2/Support/LocalActivityExecutor.php b/src/V2/Support/LocalActivityExecutor.php index 0824664a3..88146f9db 100644 --- a/src/V2/Support/LocalActivityExecutor.php +++ b/src/V2/Support/LocalActivityExecutor.php @@ -270,40 +270,36 @@ public function execute( /** @var ActivityAttempt $attempt */ $attempt = $started['attempt']; - $activityClass = TypeRegistry::resolveActivityClass($execution->activity_class, $execution->activity_type); - $activity = RuntimeObjectFactory::activity($activityClass, $execution, $run, $task->id); - $entryMethod = EntryMethod::forActivity($activity); - $activityArguments = $execution->activityArguments(); - $arguments = $activity->resolveMethodDependencies($activityArguments, $entryMethod); - - $result = null; - $throwable = null; - - if (WorkflowStub::faked()) { - WorkflowStub::recordDispatched($execution->activity_class, $activityArguments); - } + $operation = fn (): array => $this->invokeCallback($run, $task, $execution); + if (ExecutionTracing::enabled() && ! WorkflowStub::hasMock($execution->activity_class)) { + $storedContext = TraceCarrier::fromRun($run, $diagnostic); + [$result, $throwable] = ExecutionTracing::localActivity( + [ + 'workflow_instance_id' => $run->workflow_instance_id, + 'workflow_run_id' => $run->id, + 'workflow_type' => $run->workflow_type, + 'namespace' => $run->namespace, + 'task_id' => $task->id, + 'activity_id' => $execution->id, + 'activity_type' => $execution->activity_type, + 'activity_attempt_id' => $attempt->id, + 'activity_attempt_number' => $attempt->attempt_number, + 'durable.trace_context.disposition' => $diagnostic, + ], + $storedContext, + static function () use ($operation): array { + [$result, $throwable] = $operation(); + if ($throwable !== null) { + ExecutionTracing::recordFailure($throwable); + } - if (WorkflowStub::hasMock($execution->activity_class)) { - $mocked = WorkflowStub::mockedResult( - $execution->activity_class, - new ActivityFakeContext( - run: $run, - execution: $execution, - taskId: $task->id, - sequence: (int) $execution->sequence, - activity: $execution->activity_class, - ), - $activityArguments, + return [$result, $throwable]; + }, + ExecutionTracing::baggageAllowlist(), + true, ); - - $result = $mocked['result']; - $throwable = $mocked['throwable']; } else { - try { - $result = $activity->{$entryMethod->getName()}(...$arguments); - } catch (Throwable $error) { - $throwable = $error; - } + [$result, $throwable] = $operation(); } return $this->recordAttemptOutcome($task, $execution, $attempt, $result, $throwable); @@ -384,6 +380,50 @@ public function recoverPortableAttempt( ]; } + /** + * @return array{mixed, Throwable|null} + */ + private function invokeCallback(WorkflowRun $run, WorkflowTask $task, ActivityExecution $execution): array + { + $activityClass = TypeRegistry::resolveActivityClass($execution->activity_class, $execution->activity_type); + $activity = RuntimeObjectFactory::activity($activityClass, $execution, $run, $task->id); + $entryMethod = EntryMethod::forActivity($activity); + $activityArguments = $execution->activityArguments(); + $arguments = $activity->resolveMethodDependencies($activityArguments, $entryMethod); + + $result = null; + $throwable = null; + + if (WorkflowStub::faked()) { + WorkflowStub::recordDispatched($execution->activity_class, $activityArguments); + } + + if (WorkflowStub::hasMock($execution->activity_class)) { + $mocked = WorkflowStub::mockedResult( + $execution->activity_class, + new ActivityFakeContext( + run: $run, + execution: $execution, + taskId: $task->id, + sequence: (int) $execution->sequence, + activity: $execution->activity_class, + ), + $activityArguments, + ); + + $result = $mocked['result']; + $throwable = $mocked['throwable']; + } else { + try { + $result = $activity->{$entryMethod->getName()}(...$arguments); + } catch (Throwable $error) { + $throwable = $error; + } + } + + return [$result, $throwable]; + } + /** @param array $report * @return array */ diff --git a/src/V2/Support/ScheduleManager.php b/src/V2/Support/ScheduleManager.php index 90561090e..00f5a3638 100644 --- a/src/V2/Support/ScheduleManager.php +++ b/src/V2/Support/ScheduleManager.php @@ -360,100 +360,16 @@ public static function triggerDetailed( ?CommandContext $context = null, ?DateTimeInterface $occurrenceTime = null, ): ScheduleTriggerResult { - return DB::transaction(static function () use ( + return ExecutionTracing::scheduleAdmission( $schedule, - $overlapPolicyOverride, - $context, - $occurrenceTime - ): ScheduleTriggerResult { - /** @var WorkflowSchedule $schedule */ - $schedule = WorkflowSchedule::query() - ->lockForUpdate() - ->findOrFail($schedule->id); - - // A tick may have selected this occurrence before another trigger - // or update changed the schedule. Decide using the locked row. - if ($occurrenceTime !== null && ( - $schedule->next_fire_at === null - || UtcScheduleTimestamp::databaseValue($schedule->next_fire_at) - !== UtcScheduleTimestamp::databaseValue($occurrenceTime) - )) { - self::recordSkip($schedule, 'stale_occurrence', $context); - - return new ScheduleTriggerResult('skipped', null, null, 'stale_occurrence'); - } - - if (! $schedule->status->allowsTrigger()) { - self::recordSkip($schedule, 'status_not_triggerable', $context); - - return new ScheduleTriggerResult('skipped', null, null, 'status_not_triggerable'); - } - - if ($schedule->remaining_actions !== null && $schedule->remaining_actions <= 0) { - self::recordSkip($schedule, 'remaining_actions_exhausted', $context); - - return new ScheduleTriggerResult('skipped', null, null, 'remaining_actions_exhausted'); - } - - if ($occurrenceTime !== null && self::hasFiredOccurrence($schedule, $occurrenceTime)) { - self::skipFiredOccurrence($schedule, $occurrenceTime, $context); - - return new ScheduleTriggerResult('skipped', null, null, 'already_fired_occurrence'); - } - - $overlapPolicy = $overlapPolicyOverride - ?? ScheduleOverlapPolicy::tryFrom($schedule->overlap_policy ?? '') - ?? ScheduleOverlapPolicy::Skip; - - if ($overlapPolicy->isBuffer() && self::runningRunExists($schedule)) { - if ($schedule->isAtBufferCapacity($overlapPolicy->value)) { - self::recordSkip($schedule, 'buffer_full', $context); - $schedule->forceFill([ - 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), - ])->save(); - - return new ScheduleTriggerResult('buffer_full', null, null, 'buffer_full'); - } - - $schedule->bufferAction($occurrenceTime); - $schedule->next_fire_at = $schedule->computeNextFireAtWithJitter(); - $schedule->save(); - - return new ScheduleTriggerResult('buffered', null, null, null); - } - - if (! self::overlapAllowed($schedule, $overlapPolicy)) { - $reason = 'overlap_policy_' . $overlapPolicy->value; - self::recordSkip($schedule, $reason, $context); - $schedule->forceFill([ - 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), - ])->save(); - - return new ScheduleTriggerResult('skipped', null, null, $reason); - } - - if ($overlapPolicy === ScheduleOverlapPolicy::CancelOther || $overlapPolicy === ScheduleOverlapPolicy::TerminateOther) { - self::closeExistingRun($schedule, $overlapPolicy); - } - - try { - $startResult = self::startRun( - $schedule, - occurrenceTime: $occurrenceTime, - effectiveOverlapPolicy: $overlapPolicy->value, - context: $context, - ); - } catch (WorkflowExecutionUnavailableException $exception) { - self::recordSkip($schedule, $exception->blockedReason(), $context); - $schedule->forceFill([ - 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), - ])->save(); - - return new ScheduleTriggerResult('skipped', null, null, $exception->blockedReason()); - } - - return new ScheduleTriggerResult('triggered', $startResult->instanceId, $startResult->runId, null); - }); + static fn (): ScheduleTriggerResult => self::triggerAtomically( + $schedule, + $overlapPolicyOverride, + $context, + $occurrenceTime + ), + $occurrenceTime === null ? 'manual' : 'scheduled' + ); } /** @@ -474,40 +390,60 @@ public static function tick(int $limit = 100): array } try { - $drained = DB::transaction(static function () use ($schedule): ?array { - /** @var WorkflowSchedule $schedule */ - $schedule = WorkflowSchedule::query()->lockForUpdate()->findOrFail($schedule->id); - - $bufferedAction = $schedule->drainBuffer(); - - if ($bufferedAction === null) { - return null; - } - - $schedule->save(); - - $occurrenceTime = self::dateTimeFromPayload($bufferedAction['occurrence_time'] ?? null); - if ($occurrenceTime !== null && self::hasFiredOccurrence($schedule, $occurrenceTime)) { - self::skipFiredOccurrence($schedule, $occurrenceTime); - - return [ - 'instance_id' => null, - 'run_id' => null, - 'outcome' => 'skipped', - 'reason' => 'already_fired_occurrence', - 'occurrence_time' => $occurrenceTime->format('Y-m-d\TH:i:s.uP'), - ]; - } - - $startResult = self::startRun($schedule, occurrenceTime: $occurrenceTime, outcome: 'drained'); - - return [ - 'instance_id' => $startResult->instanceId, - 'run_id' => $startResult->runId, - 'outcome' => 'drained', - 'occurrence_time' => $occurrenceTime?->format('Y-m-d\TH:i:s.uP'), - ]; - }); + $drained = ExecutionTracing::scheduleAdmission( + $schedule, + static function () use ($schedule): ?array { + $drained = DB::transaction(static function () use ($schedule): ?array { + /** @var WorkflowSchedule $schedule */ + $schedule = WorkflowSchedule::query()->lockForUpdate()->findOrFail($schedule->id); + + $bufferedAction = $schedule->drainBuffer(); + + if ($bufferedAction === null) { + return null; + } + + $schedule->save(); + + $occurrenceTime = self::dateTimeFromPayload($bufferedAction['occurrence_time'] ?? null); + if ($occurrenceTime !== null && self::hasFiredOccurrence($schedule, $occurrenceTime)) { + self::skipFiredOccurrence($schedule, $occurrenceTime); + + return [ + 'instance_id' => null, + 'run_id' => null, + 'outcome' => 'skipped', + 'reason' => 'already_fired_occurrence', + 'occurrence_time' => $occurrenceTime->format('Y-m-d\TH:i:s.uP'), + ]; + } + + $startResult = self::startRun( + $schedule, + occurrenceTime: $occurrenceTime, + outcome: 'drained' + ); + + return [ + 'instance_id' => $startResult->instanceId, + 'run_id' => $startResult->runId, + 'outcome' => 'drained', + 'occurrence_time' => $occurrenceTime?->format('Y-m-d\TH:i:s.uP'), + 'observability_context' => $bufferedAction['observability_context'] ?? null, + ]; + }); + if ($drained !== null) { + ExecutionTracing::linkBufferedSchedule( + $schedule, + $drained['observability_context'] ?? null + ); + unset($drained['observability_context']); + } + + return $drained; + }, + 'buffered' + ); if ($drained !== null) { $schedule->refresh(); @@ -671,6 +607,111 @@ public static function backfill( return $results; } + private static function triggerAtomically( + WorkflowSchedule $schedule, + ?ScheduleOverlapPolicy $overlapPolicyOverride, + ?CommandContext $context, + ?DateTimeInterface $occurrenceTime, + ): ScheduleTriggerResult { + $observabilityContext = ExecutionTracing::enabled() ? TraceCarrier::admissionParent() : null; + + return DB::transaction(static function () use ( + $schedule, + $overlapPolicyOverride, + $context, + $occurrenceTime, + $observabilityContext + ): ScheduleTriggerResult { + /** @var WorkflowSchedule $schedule */ + $schedule = WorkflowSchedule::query() + ->lockForUpdate() + ->findOrFail($schedule->id); + + // A tick may have selected this occurrence before another trigger + // or update changed the schedule. Decide using the locked row. + if ($occurrenceTime !== null && ( + $schedule->next_fire_at === null + || UtcScheduleTimestamp::databaseValue($schedule->next_fire_at) + !== UtcScheduleTimestamp::databaseValue($occurrenceTime) + )) { + self::recordSkip($schedule, 'stale_occurrence', $context); + + return new ScheduleTriggerResult('skipped', null, null, 'stale_occurrence'); + } + + if (! $schedule->status->allowsTrigger()) { + self::recordSkip($schedule, 'status_not_triggerable', $context); + + return new ScheduleTriggerResult('skipped', null, null, 'status_not_triggerable'); + } + + if ($schedule->remaining_actions !== null && $schedule->remaining_actions <= 0) { + self::recordSkip($schedule, 'remaining_actions_exhausted', $context); + + return new ScheduleTriggerResult('skipped', null, null, 'remaining_actions_exhausted'); + } + + if ($occurrenceTime !== null && self::hasFiredOccurrence($schedule, $occurrenceTime)) { + self::skipFiredOccurrence($schedule, $occurrenceTime, $context); + + return new ScheduleTriggerResult('skipped', null, null, 'already_fired_occurrence'); + } + + $overlapPolicy = $overlapPolicyOverride + ?? ScheduleOverlapPolicy::tryFrom($schedule->overlap_policy ?? '') + ?? ScheduleOverlapPolicy::Skip; + + if ($overlapPolicy->isBuffer() && self::runningRunExists($schedule)) { + if ($schedule->isAtBufferCapacity($overlapPolicy->value)) { + self::recordSkip($schedule, 'buffer_full', $context); + $schedule->forceFill([ + 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), + ])->save(); + + return new ScheduleTriggerResult('buffer_full', null, null, 'buffer_full'); + } + + $schedule->bufferAction($occurrenceTime, $observabilityContext); + $schedule->next_fire_at = $schedule->computeNextFireAtWithJitter(); + $schedule->save(); + + return new ScheduleTriggerResult('buffered', null, null, null); + } + + if (! self::overlapAllowed($schedule, $overlapPolicy)) { + $reason = 'overlap_policy_' . $overlapPolicy->value; + self::recordSkip($schedule, $reason, $context); + $schedule->forceFill([ + 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), + ])->save(); + + return new ScheduleTriggerResult('skipped', null, null, $reason); + } + + if ($overlapPolicy === ScheduleOverlapPolicy::CancelOther || $overlapPolicy === ScheduleOverlapPolicy::TerminateOther) { + self::closeExistingRun($schedule, $overlapPolicy); + } + + try { + $startResult = self::startRun( + $schedule, + occurrenceTime: $occurrenceTime, + effectiveOverlapPolicy: $overlapPolicy->value, + context: $context, + ); + } catch (WorkflowExecutionUnavailableException $exception) { + self::recordSkip($schedule, $exception->blockedReason(), $context); + $schedule->forceFill([ + 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), + ])->save(); + + return new ScheduleTriggerResult('skipped', null, null, $exception->blockedReason()); + } + + return new ScheduleTriggerResult('triggered', $startResult->instanceId, $startResult->runId, null); + }); + } + /** * Select a bounded oldest-first batch without allowing one namespace to * occupy every slot. Namespace order is based on each namespace's oldest @@ -783,6 +824,24 @@ private static function triggerForBackfill( ScheduleOverlapPolicy $overlapPolicy, DateTimeInterface $occurrenceTime, ?CommandContext $context = null, + ): ?string { + return ExecutionTracing::scheduleAdmission( + $schedule, + static fn (): ?string => self::triggerBackfillAtomically( + $schedule, + $overlapPolicy, + $occurrenceTime, + $context + ), + 'backfill' + ); + } + + private static function triggerBackfillAtomically( + WorkflowSchedule $schedule, + ScheduleOverlapPolicy $overlapPolicy, + DateTimeInterface $occurrenceTime, + ?CommandContext $context, ): ?string { $effectivePolicy = $overlapPolicy->isBuffer() ? ScheduleOverlapPolicy::AllowAll diff --git a/src/V2/Support/TaskDispatcher.php b/src/V2/Support/TaskDispatcher.php index f0cb35b65..ebb0915d5 100644 --- a/src/V2/Support/TaskDispatcher.php +++ b/src/V2/Support/TaskDispatcher.php @@ -81,7 +81,16 @@ private static function publish( return; } - app(BusDispatcher::class)->dispatch($job); + if (ExecutionTracing::enabled()) { + ExecutionTracing::dispatch( + $task, + $job, + static fn () => app(BusDispatcher::class)->dispatch($job), + true + ); + } else { + app(BusDispatcher::class)->dispatch($job); + } self::markDispatched($task, $attemptedAt, $boundedWorkflowTaskProjection, $job); } catch (Throwable $throwable) { self::markDispatchFailure($task, $attemptedAt, $throwable->getMessage(), $boundedWorkflowTaskProjection); @@ -137,7 +146,7 @@ private static function fleetBlockReason(WorkflowTask $task): ?string : 'Dispatch blocked under fail validation mode. ' . $reason; } - private static function makeJob(WorkflowTask $task): object + private static function makeJob(WorkflowTask $task): RunWorkflowTask|RunActivityTask|RunTimerTask { $job = match ($task->task_type) { TaskType::Workflow => new RunWorkflowTask($task->id), diff --git a/src/V2/Support/TaskOutcomeCarrier.php b/src/V2/Support/TaskOutcomeCarrier.php new file mode 100644 index 000000000..43f2b4d41 --- /dev/null +++ b/src/V2/Support/TaskOutcomeCarrier.php @@ -0,0 +1,293 @@ +isRecording()) { + return null; + } + $headers = []; + // The official propagator reads the SDK span directly; application hooks are not invoked. + TraceContextPropagator::getInstance()->inject($headers, context: $span->storeInContext(Context::getRoot())); + + return TraceCarrier::normalize([ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => $headers, + ]); + } + + public static function identity(WorkflowHistoryEvent $event): array + { + return match ($event->event_type->value) { + 'ActivityCompleted', 'ActivityFailed', 'ActivityTimedOut' => [ + 'activity_id' => $event->payload['activity_execution_id'] ?? null, + 'activity_attempt_id' => $event->payload['activity_attempt_id'] ?? null, + ], + 'TimerFired' => [ + 'timer_id' => $event->payload['timer_id'] ?? null, + ], + default => [], + }; + } + + public static function fromTask(WorkflowTask $task): ?array + { + $attributes = $task->getAttributes(); + $value = $attributes['observability_context'] ?? null; + if (is_string($value)) { + if (strlen($value) > 4096) { + return null; + } + try { + $value = json_decode($value, true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable) { + return null; + } + } + if (! is_array($value) || ($value['schema'] ?? null) !== self::SCHEMA || ($value['version'] ?? null) !== 1 + || ($value['task_id'] ?? null) !== ($attributes['id'] ?? null) + || ($value['run_id'] ?? null) !== ($attributes['workflow_run_id'] ?? null) + || ($value['namespace'] ?? null) !== ($attributes['namespace'] ?? null) + || ! self::identifier($value['task_id'] ?? null) || ! self::identifier($value['run_id'] ?? null) + || ! self::identifier($value['namespace'] ?? null) + || array_diff( + array_keys($value), + ['schema', 'version', 'task_id', 'run_id', 'namespace', 'outcomes'] + ) !== [] + || ! is_array($value['outcomes'] ?? null) || ! array_is_list($value['outcomes']) + || count($value['outcomes']) > 32) { + return null; + } + foreach ($value['outcomes'] as $index => $entry) { + $entry = self::entry($entry); + if ($entry === null) { + return null; + } + $value['outcomes'][$index] = $entry; + } + + return strlen(json_encode($value, JSON_THROW_ON_ERROR)) <= 4096 ? $value : null; + } + + public static function append(WorkflowTask $task, array $entries): void + { + $attributes = $task->getAttributes(); + if ($entries === [] || ! array_key_exists('observability_context', $attributes)) { + return; + } + $stored = $attributes['observability_context'] === null ? [ + 'schema' => self::SCHEMA, + 'version' => 1, + 'task_id' => $attributes['id'], + 'run_id' => $attributes['workflow_run_id'], + 'namespace' => $attributes['namespace'], + 'outcomes' => [], + ] : self::fromTask($task); + if ($stored === null) { + return; + } + $seen = array_fill_keys(array_column($stored['outcomes'], 'event_id'), true); + $changed = false; + foreach ($entries as $entry) { + $entry = self::entry($entry); + if ($entry === null || isset($seen[$entry['event_id']]) || count($stored['outcomes']) >= 32) { + continue; + } + $next = $stored; + $next['outcomes'][] = $entry; + if (strlen(json_encode($next, JSON_THROW_ON_ERROR)) <= 4096) { + $stored = $next; + $seen[$entry['event_id']] = true; + $changed = true; + } + } + if ($changed) { + // Preserve normal task casts and business attributes without invoking a telemetry setter. + $attributes['observability_context'] = json_encode($stored, JSON_THROW_ON_ERROR); + $task->setRawAttributes($attributes); + } + } + + public static function entry(mixed $value): ?array + { + if (! is_array($value) || ! self::identifier($value['event_id'] ?? null) + || ! is_int($value['sequence'] ?? null) || $value['sequence'] < 1 + || ! in_array($value['event_type'] ?? null, self::EVENTS, true) + || array_diff(array_keys($value), ['event_id', 'sequence', 'event_type', 'activity_id', + 'activity_attempt_id', 'timer_id', 'context']) !== [] + || ! is_array($value['context'] ?? null) + || array_diff(array_keys($value['context']), ['schema', 'version', 'carrier']) !== [] + || ! is_array($value['context']['carrier'] ?? null) + || array_diff(array_keys($value['context']['carrier']), ['traceparent', 'tracestate']) !== []) { + return null; + } + $identityKeys = match ($value['event_type']) { + 'ActivityCompleted', 'ActivityFailed', 'ActivityTimedOut' => ['activity_id', 'activity_attempt_id'], + 'TimerFired' => ['timer_id'], + default => [], + }; + foreach (['activity_id', 'activity_attempt_id', 'timer_id'] as $key) { + if (in_array($key, $identityKeys, true) ? ! self::identifier($value[$key] ?? null) : array_key_exists( + $key, + $value + )) { + return null; + } + } + $context = TraceCarrier::normalize($value['context']); + + return $context === null ? null : array_replace($value, [ + 'context' => $context, + ]); + } + + public static function matchesEvent(array $entry, WorkflowHistoryEvent $event): bool + { + if (($entry['event_id'] ?? null) !== $event->id || ($entry['sequence'] ?? null) !== (int) $event->sequence + || ($entry['event_type'] ?? null) !== $event->event_type->value) { + return false; + } + foreach (self::identity($event) as $key => $value) { + if (($entry[$key] ?? null) !== $value) { + return false; + } + } + + return true; + } + + public static function forRun(WorkflowRun $run, WorkflowTask $claim): array + { + try { + if (! array_key_exists('observability_context', $claim->getRawOriginal())) { + return []; + } + return self::ownRunOutcomes($run) + self::childRunOutcomes($run); + } catch (Throwable) { + // A missing legacy column or unavailable observation cannot replace workflow execution. + return []; + } + } + + private static function ownRunOutcomes(WorkflowRun $run): array + { + $events = ConfiguredV2Models::query('history_event_model', WorkflowHistoryEvent::class) + ->where('workflow_run_id', $run->id) + ->whereIn('event_type', self::EVENTS) + ->whereNotNull('workflow_task_id') + ->orderByDesc('sequence') + ->limit(32) + ->get(['id', 'workflow_run_id', 'workflow_task_id', 'sequence', 'event_type']); + if ($events->isEmpty()) { + return []; + } + $tasks = ConfiguredV2Models::query('task_model', WorkflowTask::class) + ->whereKey($events->pluck('workflow_task_id')->unique()) + ->where('workflow_run_id', $run->id) + ->where('namespace', $run->namespace) + ->get(['id', 'workflow_run_id', 'namespace', 'observability_context'])->keyBy('id'); + $result = []; + foreach ($events as $event) { + $task = $tasks->get($event->workflow_task_id); + if (! $task instanceof WorkflowTask) { + continue; + } + $envelope = self::fromTask($task); + foreach ($envelope['outcomes'] ?? [] as $entry) { + if ($entry['event_id'] === $event->id && $entry['sequence'] === (int) $event->sequence + && $entry['event_type'] === $event->event_type->value) { + $result[$event->id] = $entry + [ + 'task_id' => $task->id, + ]; + } + } + } + + return $result; + } + + private static function childRunOutcomes(WorkflowRun $run): array + { + try { + $links = ConfiguredV2Models::query('link_model', WorkflowLink::class) + ->where('parent_workflow_run_id', $run->id) + ->where('parent_workflow_instance_id', $run->workflow_instance_id) + ->where('link_type', 'child_workflow') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit(32) + ->get(['id', 'sequence', 'child_workflow_run_id', 'child_workflow_instance_id']) + ->keyBy('child_workflow_run_id'); + if ($links->isEmpty()) { + return []; + } + $events = ConfiguredV2Models::query('history_event_model', WorkflowHistoryEvent::class) + ->whereIn('workflow_run_id', $links->keys()) + ->whereIn('event_type', ['WorkflowCompleted', 'WorkflowFailed']) + ->whereNotNull('workflow_task_id') + ->orderByDesc('recorded_at') + ->orderByDesc('id') + ->limit(32) + ->get(['id', 'workflow_run_id', 'workflow_task_id', 'sequence', 'event_type']); + $tasks = ConfiguredV2Models::query('task_model', WorkflowTask::class) + ->whereKey($events->pluck('workflow_task_id')->unique()) + ->whereIn('workflow_run_id', $links->keys()) + ->get(['id', 'workflow_run_id', 'namespace', 'observability_context'])->keyBy('id'); + $result = []; + foreach ($events as $event) { + $task = $tasks->get($event->workflow_task_id); + $link = $links->get($event->workflow_run_id); + if (! $task instanceof WorkflowTask || ! $link instanceof WorkflowLink + || $task->workflow_run_id !== $event->workflow_run_id) { + continue; + } + foreach (self::fromTask($task)['outcomes'] ?? [] as $entry) { + if ($entry['event_id'] === $event->id && $entry['sequence'] === (int) $event->sequence + && $entry['event_type'] === $event->event_type->value) { + $result['child:' . $event->workflow_run_id] ??= $entry + [ + 'task_id' => $task->id, + 'child_run_id' => $task->workflow_run_id, + 'child_instance_id' => $link->child_workflow_instance_id, + 'child_namespace' => $task->namespace, + 'parent_run_id' => $run->id, + 'parent_namespace' => $run->namespace, + 'parent_sequence' => $link->sequence, + 'link_id' => $link->id, + ]; + } + } + } + + return $result; + } catch (Throwable) { + // Missing child observation rows cannot invalidate the run's own observations. + return []; + } + } + + private static function identifier(mixed $value): bool + { + return is_string($value) && $value !== '' && strlen($value) <= 512 && ! preg_match('/[\x00-\x1f\x7f]/', $value); + } +} diff --git a/src/V2/Support/TaskOutcomeTraceState.php b/src/V2/Support/TaskOutcomeTraceState.php new file mode 100644 index 000000000..14402c259 --- /dev/null +++ b/src/V2/Support/TaskOutcomeTraceState.php @@ -0,0 +1,117 @@ +fields[$key] ?? null)) { + return; + } + } + $id = $fields['activity_attempt_id'] ?? null; + if (is_string($id) && count($this->local) < 32) { + $this->local[$id] ??= [ + 'fields' => $fields, + 'context' => $context, + ]; + } + } + + public function observe(WorkflowRun $run, WorkflowTask $task, WorkflowHistoryEvent $event): void + { + if (! $this->owns($task) || $event->workflow_task_id !== $task->id || $event->workflow_run_id !== $run->id + || $run->id !== $task->workflow_run_id || $run->namespace !== $task->namespace + || ! in_array($event->event_type->value, TaskOutcomeCarrier::EVENTS, true)) { + return; + } + $identity = TaskOutcomeCarrier::identity($event); + $source = $this->source($event, $identity); + if ($source === null) { + return; + } + foreach ($identity as $key => $value) { + if ($value === null || ($source['fields'][$key] ?? null) !== $value) { + return; + } + } + $entry = TaskOutcomeCarrier::entry([ + 'event_id' => $event->id, + 'sequence' => (int) $event->sequence, + 'event_type' => $event->event_type->value, + ...$identity, + 'context' => $source['context'], + ]); + if ($entry !== null && count($this->entries) < 32) { + $this->connection = $task->getConnection() + ->getName(); + $this->entries[$event->id] ??= $entry; + } + } + + public function apply(WorkflowTask $task): void + { + if ($this->owns($task)) { + TaskOutcomeCarrier::append($task, $this->entries); + } + } + + public function discard(string $connection): void + { + if ($this->connection === $connection) { + $this->entries = []; + } + } + + private function owns(WorkflowTask $task): bool + { + return ($this->fields['task_id'] ?? null) === $task->id + && ($this->fields['workflow_run_id'] ?? null) === $task->workflow_run_id + && ($this->fields['namespace'] ?? null) === $task->namespace; + } + + private function source(WorkflowHistoryEvent $event, array $identity): ?array + { + if (isset($identity['activity_attempt_id'])) { + if ($this->kind === 'durable.activity.attempt') { + return [ + 'fields' => $this->fields, + 'context' => $this->context, + ]; + } + return $this->local[$identity['activity_attempt_id']] ?? null; + } + if (($event->event_type->value === 'TimerFired' && $this->kind === 'durable.timer.task') + || (in_array($event->event_type->value, ['WorkflowCompleted', 'WorkflowFailed'], true) + && $this->kind === 'durable.workflow.task')) { + return [ + 'fields' => $this->fields, + 'context' => $this->context, + ]; + } + + return null; + } +} diff --git a/src/V2/Support/TaskTraceCarrier.php b/src/V2/Support/TaskTraceCarrier.php new file mode 100644 index 000000000..8d579fcfc --- /dev/null +++ b/src/V2/Support/TaskTraceCarrier.php @@ -0,0 +1,75 @@ +|null + */ + public static function capture(string $taskId): ?array + { + if (! self::validTaskId($taskId)) { + return null; + } + $context = TraceCarrier::capture(diagnostic: $diagnostic); + ExecutionTracing::recordCarrierDiagnostic($diagnostic); + if ($context === null) { + return null; + } + + $envelope = [ + 'schema' => self::SCHEMA, + 'version' => 1, + 'task_id' => $taskId, + 'context' => $context, + ]; + try { + return strlen(json_encode($envelope, JSON_THROW_ON_ERROR)) <= 4096 ? $envelope : null; + } catch (Throwable) { + return null; + } + } + + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function fromJob(mixed $value, string $taskId, ?string &$diagnostic = null): ?array + { + $diagnostic = $value === null ? null : 'discarded'; + if (! self::validTaskId($taskId) || ! is_array($value) || ($value['schema'] ?? null) !== self::SCHEMA + || ($value['version'] ?? null) !== 1 || ($value['task_id'] ?? null) !== $taskId + || array_diff(array_keys($value), ['schema', 'version', 'task_id', 'context']) !== [] + || ! is_array($value['context'] ?? null) || ! is_array($value['context']['carrier'] ?? null) + || array_diff(array_keys($value['context']['carrier']), ['traceparent', 'tracestate']) !== []) { + return null; + } + $context = TraceCarrier::normalize($value['context'], diagnostic: $contextDiagnostic); + if ($context === null) { + return null; + } + + try { + if (strlen(json_encode($value, JSON_THROW_ON_ERROR)) > 4096) { + return null; + } + $diagnostic = $contextDiagnostic; + + return $context; + } catch (Throwable) { + return null; + } + } + + private static function validTaskId(string $taskId): bool + { + return $taskId !== '' && strlen($taskId) <= 512 && ! preg_match('/[\x00-\x1f\x7f]/', $taskId); + } +} diff --git a/src/V2/Support/TaskTraceTimings.php b/src/V2/Support/TaskTraceTimings.php new file mode 100644 index 000000000..67037e5c1 --- /dev/null +++ b/src/V2/Support/TaskTraceTimings.php @@ -0,0 +1,101 @@ + + */ + public static function forTask(WorkflowTask $task, ?WorkflowTimer $timer = null): array + { + $attributes = []; + try { + $seconds = self::elapsed(self::date($task, 'available_at'), self::date($task, 'leased_at')); + if ($seconds !== null) { + $attributes['durable.task.queue_wait_seconds'] = $seconds; + } + } catch (Throwable) { + // Optional date casts must not prevent execution. + } + try { + $payload = $task->payload; + $backoff = is_array($payload) ? ($payload['retry_backoff_seconds'] ?? null) : null; + if ($task->task_type === TaskType::Activity && is_int($backoff) && $backoff >= 0 + && is_int($payload['retry_after_attempt'] ?? null) && $payload['retry_after_attempt'] > 0 + && is_string($payload['retry_after_attempt_id'] ?? null) && $payload['retry_after_attempt_id'] !== '') { + $attributes['durable.task.retry_backoff_seconds'] = $backoff; + } + } catch (Throwable) { + // Unreadable retry metadata supplies no inferred backoff. + } + try { + if ($timer === null || $task->task_type !== TaskType::Timer + || $timer->workflow_run_id !== $task->workflow_run_id + || $timer->id !== ($task->payload['timer_id'] ?? null)) { + return $attributes; + } + $delay = $timer->delay_seconds; + if (is_int($delay) && $delay >= 0) { + $attributes['durable.timer.requested_wait_seconds'] = $delay; + } + $seconds = self::elapsed(self::date($timer, 'fire_at'), self::date($task, 'leased_at')); + if ($seconds !== null) { + $attributes['durable.timer.lateness_seconds'] = $seconds; + } + } catch (Throwable) { + // Timer authority and outcomes do not depend on telemetry reads. + } + + return $attributes; + } + + private static function date(WorkflowTask|WorkflowTimer $model, string $field): ?DateTimeInterface + { + $value = $model->getAttribute($field); + if (! $value instanceof DateTimeInterface) { + return null; + } + $raw = $model->getRawOriginal($field); + if ($raw instanceof DateTimeInterface) { + return $value; + } + if (! is_string($raw)) { + return null; + } + $parsed = DateTimeImmutable::createFromFormat( + str_contains($raw, '.') ? '!Y-m-d H:i:s.u' : '!Y-m-d H:i:s', + $raw, + new DateTimeZone('UTC'), + ); + $errors = DateTimeImmutable::getLastErrors(); + if ($parsed === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) + || $parsed->format('Y-m-d H:i:s.u') !== $value->format('Y-m-d H:i:s.u')) { + return null; + } + + return $value; + } + + private static function elapsed(?DateTimeInterface $start, ?DateTimeInterface $end): ?float + { + if (! $start instanceof DateTimeInterface || ! $end instanceof DateTimeInterface) { + return null; + } + $seconds = ($end->getTimestamp() - $start->getTimestamp()) + + ((int) $end->format('u') - (int) $start->format('u')) / 1_000_000; + + return is_finite($seconds) && $seconds >= 0 ? $seconds : null; + } +} diff --git a/src/V2/Support/TraceCarrier.php b/src/V2/Support/TraceCarrier.php new file mode 100644 index 000000000..641e01ab9 --- /dev/null +++ b/src/V2/Support/TraceCarrier.php @@ -0,0 +1,314 @@ + 256, + 'tracestate' => 512, + 'baggage' => 2048, + ]; + + private static ?ContextKeyInterface $admissionKey = null; + + /** + * @var WeakMap|null + */ + private static ?WeakMap $admissionFibers = null; + + public static function withPreparedAdmission(callable $operation): mixed + { + $fiber = Fiber::getCurrent(); + try { + self::$admissionKey ??= Context::createKey('durable-workflow.schedule-admission'); + $carrier = self::captureConfigured($diagnostic); + $scope = Context::getCurrent()->with(self::$admissionKey, [ + 'carrier' => $carrier, + 'diagnostic' => $diagnostic, + 'fiber' => $fiber, + ])->activate(); + if ($fiber !== null) { + self::$admissionFibers ??= new WeakMap(); + self::$admissionFibers[$fiber] = (self::$admissionFibers[$fiber] ?? 0) + 1; + } + } catch (Throwable) { + return $operation(); + } + + try { + return $operation(); + } finally { + try { + $scope->detach(); + } catch (Throwable) { + // Optional carrier scope cleanup cannot replace the business result. + } finally { + if ($fiber !== null && isset(self::$admissionFibers[$fiber])) { + $depth = self::$admissionFibers[$fiber] - 1; + if ($depth === 0) { + unset(self::$admissionFibers[$fiber]); + } else { + self::$admissionFibers[$fiber] = $depth; + } + } + } + } + } + + public static function hasPreparedAdmission(): bool + { + return self::preparedAdmission() !== null; + } + + public static function preparedAdmissionDiagnostic(): ?string + { + return self::preparedAdmission()['diagnostic'] ?? null; + } + + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function admissionParent(?string &$diagnostic = null): ?array + { + $prepared = self::preparedAdmission(); + if ($prepared !== null) { + $diagnostic = $prepared['diagnostic']; + + return $prepared['carrier']; + } + + return self::captureConfigured($diagnostic); + } + + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function captureConfigured(?string &$diagnostic = null): ?array + { + $diagnostic = null; + try { + if (! ExecutionTracing::enabled()) { + return null; + } + $carrier = self::capture(ExecutionTracing::baggageAllowlist(), $diagnostic); + ExecutionTracing::recordCarrierDiagnostic($diagnostic); + + return $carrier; + } catch (Throwable) { + $diagnostic = 'discarded'; + + return null; + } + } + + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function fromCommand(WorkflowCommand $command, ?string &$diagnostic = null): ?array + { + $diagnostic = null; + try { + $stored = array_key_exists('observability_context', $command->getAttributes()) + ? $command->getAttribute('observability_context') : null; + + // A link never imports the triggering caller's baggage into execution. + return self::normalize($stored, diagnostic: $diagnostic); + } catch (Throwable) { + $diagnostic = 'discarded'; + + return null; + } + } + + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function fromRun(WorkflowRun $run, ?string &$diagnostic = null): ?array + { + $diagnostic = null; + try { + // Emission can be disabled without erasing a previously captured admission parent. + $stored = array_key_exists('observability_context', $run->getAttributes()) + ? $run->getAttribute('observability_context') : null; + if ($stored === null) { + return null; + } + return self::normalize($stored, ExecutionTracing::baggageAllowlist(), $diagnostic); + } catch (Throwable) { + // Optional telemetry metadata must not interrupt child admission or retries. + $diagnostic = 'discarded'; + + return null; + } + } + + /** + * @param list $baggageAllowlist + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function capture(array $baggageAllowlist = [], ?string &$diagnostic = null): ?array + { + $diagnostic = null; + if (! class_exists(Globals::class)) { + return null; + } + + try { + $carrier = []; + Globals::propagator()->inject($carrier); + + // Applications may also inject unrelated propagation formats. + $carrier = array_intersect_key($carrier, self::LIMITS); + if ($carrier === []) { + return null; + } + + return self::normalize([ + 'schema' => self::SCHEMA, + 'version' => 1, + 'carrier' => $carrier, + ], $baggageAllowlist, $diagnostic); + } catch (Throwable) { + $diagnostic = 'discarded'; + + return null; + } + } + + /** + * @param list $baggageAllowlist + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function normalize( + mixed $value, + array $baggageAllowlist = [], + ?string &$diagnostic = null, + ): ?array { + $diagnostic = $value === null ? null : 'discarded'; + if (! is_array($value) || ($value['schema'] ?? null) !== self::SCHEMA + || ($value['version'] ?? null) !== 1 || ! is_array($value['carrier'] ?? null) + || array_diff(array_keys($value), ['schema', 'version', 'carrier']) !== [] + || ! class_exists(TraceContextPropagator::class)) { + return null; + } + + $carrier = $value['carrier']; + if (! isset($carrier['traceparent']) || array_diff(array_keys($carrier), array_keys(self::LIMITS)) !== []) { + return null; + } + foreach ($carrier as $key => $header) { + if (! is_string($header) || strlen($header) > self::LIMITS[$key]) { + return null; + } + } + + try { + if (strlen(json_encode($value, JSON_THROW_ON_ERROR)) > 4096) { + return null; + } + + // Invalid metadata must never fall back to a worker's ambient span. + $context = TraceContextPropagator::getInstance()->extract($carrier, context: Context::getRoot()); + if (! Span::fromContext($context)->getContext()->isValid()) { + return null; + } + + $normalized = []; + TraceContextPropagator::getInstance()->inject($normalized, context: $context); + $baggage = self::normalizedBaggage($carrier, $baggageAllowlist); + if ($baggage === null) { + return null; + } + $normalized = array_replace($normalized, $baggage); + + if (isset($normalized['baggage']) && strlen($normalized['baggage']) > self::LIMITS['baggage']) { + return null; + } + $result = [ + 'schema' => self::SCHEMA, + 'version' => 1, + 'carrier' => $normalized, + ]; + + if (strlen(json_encode($result, JSON_THROW_ON_ERROR)) > 4096) { + return null; + } + $diagnostic = $normalized === $carrier ? null : 'filtered'; + + return $result; + } catch (Throwable) { + return null; + } + } + + private static function preparedAdmission(): ?array + { + $fiber = Fiber::getCurrent(); + if ($fiber !== null && ! isset(self::$admissionFibers[$fiber])) { + return null; + } + try { + $prepared = self::$admissionKey === null ? null : Context::getCurrent()->get(self::$admissionKey); + + return is_array($prepared) && array_key_exists('carrier', $prepared) + && ($prepared['fiber'] ?? null) === $fiber ? $prepared : null; + } catch (Throwable) { + return null; + } + } + + /** + * @param array $carrier + * @param list $allowlist + * @return array|null + */ + private static function normalizedBaggage(array $carrier, array $allowlist): ?array + { + if ($allowlist === [] || ! isset($carrier['baggage'])) { + return []; + } + $context = BaggagePropagator::getInstance()->extract($carrier, context: Context::getRoot()); + $builder = Baggage::getBuilder(); + $count = 0; + foreach (Baggage::fromContext($context)->getAll() as $key => $entry) { + if (! in_array($key, $allowlist, true)) { + continue; + } + if (++$count > 32) { + return null; + } + $builder->set($key, $entry->getValue(), $entry->getMetadata()); + } + $normalized = []; + BaggagePropagator::getInstance()->inject( + $normalized, + context: $builder->build() + ->storeInContext(Context::getRoot()), + ); + + return $normalized; + } +} diff --git a/src/V2/Support/TraceLinkState.php b/src/V2/Support/TraceLinkState.php new file mode 100644 index 000000000..9f8120482 --- /dev/null +++ b/src/V2/Support/TraceLinkState.php @@ -0,0 +1,61 @@ +span->isRecording(); + } + + public function setOutcomes(array $outcomes): void + { + $this->outcomes = $outcomes; + } + + public function outcome(string $id): ?array + { + return $this->outcomes[$id] ?? null; + } + + public function markOutcomeUnavailable(): void + { + $this->span->setAttribute('durable.links.outcome_context_unavailable', true); + } + + /** + * @param array $attributes + */ + public function add(SpanContextInterface $context, array $attributes): void + { + $identity = $context->getTraceId() . ':' . $context->getSpanId(); + if (isset($this->seen[$identity])) { + return; + } + if (count($this->seen) >= 32) { + $this->span->setAttribute('durable.links.dropped', ++$this->dropped); + + return; + } + $this->span->addLink($context, $attributes); + $this->seen[$identity] = true; + } +} diff --git a/src/V2/Support/WorkflowContinuationCache.php b/src/V2/Support/WorkflowContinuationCache.php index 43cab75be..6d6a22fb1 100644 --- a/src/V2/Support/WorkflowContinuationCache.php +++ b/src/V2/Support/WorkflowContinuationCache.php @@ -45,7 +45,13 @@ public function __construct() public function session(?object $job, int $claimedAttempt): ?WorkflowContinuationSession { - if (! $job instanceof DatabaseJob || PHP_SAPI !== 'cli' || WorkflowStub::faked() + // Retained Fibers keep their entry context. Traced tasks need a fresh + // Fiber until restoring the new task context on each resume is qualified. + $tracing = ExecutionTracing::enabled(); + if ($tracing) { + $this->clear('evict_tracing'); + } + if ($tracing || ! $job instanceof DatabaseJob || PHP_SAPI !== 'cli' || WorkflowStub::faked() || config('workflows.v2.live_execution.workflows', []) === []) { return null; } @@ -97,6 +103,10 @@ public function take(string $key, ?string $scope = null): ?WorkflowContinuation public function publish(string $key, int $generation, WorkflowContinuation $entry): void { + if (ExecutionTracing::enabled()) { + $this->clear('evict_tracing'); + return; + } $this->trim(); $now = hrtime(true); if ($entry->retainedAt !== 0 && $now - $entry->retainedAt >= 30_000_000_000) { diff --git a/src/V2/Support/WorkflowContinuationSession.php b/src/V2/Support/WorkflowContinuationSession.php index 9ad3afd48..f91c03366 100644 --- a/src/V2/Support/WorkflowContinuationSession.php +++ b/src/V2/Support/WorkflowContinuationSession.php @@ -45,13 +45,21 @@ public function __construct( public function begin(WorkflowRun $run, WorkflowTask $task, string $class): ?WorkflowContinuation { $this->observe($run); + $tracing = ExecutionTracing::enabled(); + if ($tracing) { + $this->cache->clear('evict_tracing'); + } $build = config('workflows.v2.live_execution.build'); - $eligible = is_string($build) && $build !== '' && $this->eligibleClass($class) + $eligible = ! $tracing && is_string($build) && $build !== '' && $this->eligibleClass($class) && $this->database === $run->getConnection() && $this->database->transactionLevel() === 1 && $this->hasAuthority($run, $task) && $this->safeHistory($run); $prefix = $eligible ? $this->prefix($run) : null; if ($prefix === null) { - $this->disable($eligible ? 'cold_history_cursor' : 'cold_profile'); + $this->disable(match (true) { + $tracing => 'cold_tracing', + $eligible => 'cold_history_cursor', + default => 'cold_profile', + }); return null; } $this->cache->count('validated_history_events', count($prefix)); diff --git a/src/V2/Support/WorkflowExecution.php b/src/V2/Support/WorkflowExecution.php index 952bcd1db..e411468b9 100644 --- a/src/V2/Support/WorkflowExecution.php +++ b/src/V2/Support/WorkflowExecution.php @@ -45,6 +45,7 @@ public static function startCallback( ?StraightLineWorkflowRequiredException $straightLineError = null, ?CarbonInterface $eventTime = null, ): self { + $callback = ExecutionTracing::bind($callback); $fiber = new Fiber(static function () use ($callback, $arguments): mixed { WorkflowFiberContext::enter(); diff --git a/src/V2/Support/WorkflowExecutor.php b/src/V2/Support/WorkflowExecutor.php index 32c98ae78..b65285f66 100644 --- a/src/V2/Support/WorkflowExecutor.php +++ b/src/V2/Support/WorkflowExecutor.php @@ -2756,6 +2756,7 @@ private function scheduleChildWorkflow( $childRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $childInstance->id, 'run_number' => 1, + 'observability_context' => TraceCarrier::fromRun($run), 'workflow_class' => $childWorkflowCall->workflow, 'workflow_type' => $workflowType, 'namespace' => $run->namespace, @@ -3228,7 +3229,7 @@ private function applySignal( is_string($run->namespace) ? $run->namespace : null, ); - return WorkflowHistoryEvent::record($run, HistoryEventType::SignalApplied, array_filter([ + $event = WorkflowHistoryEvent::record($run, HistoryEventType::SignalApplied, array_filter([ 'workflow_command_id' => $command->id, 'signal_id' => $signal?->id, 'signal_name' => $signalCall->name, @@ -3237,6 +3238,11 @@ private function applySignal( 'value' => ExternalPayloads::historyValue($storedValue, $run->payload_codec, $run->namespace), ...($parallelMetadata ?? []), ], static fn (mixed $payloadValue): bool => $payloadValue !== null), $task, $command); + if (ExecutionTracing::enabled()) { + ExecutionTracing::linkSignal($run, $command); + } + + return $event; } private function recordSignalWait( @@ -3698,6 +3704,7 @@ private function continueAsNew( $continuedRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $run->workflow_instance_id, 'run_number' => $run->run_number + 1, + 'observability_context' => TraceCarrier::fromRun($run), 'workflow_class' => $workflowClass, 'workflow_type' => $run->workflow_type, 'namespace' => $run->namespace, @@ -4339,6 +4346,10 @@ private function failRun( throw $throwable; } + if (ExecutionTracing::enabled()) { + ExecutionTracing::recordFailure($throwable); + } + if ($throwable instanceof UnresolvedWorkflowFailureException) { $this->blockReplayUntilFailureCanBeRestored($run, $task, $throwable); @@ -4346,20 +4357,7 @@ private function failRun( } if ($throwable instanceof ActivityRetryPolicyMismatchException) { - $payload = is_array($task->payload) ? $task->payload : []; - $payload['replay_blocked'] = true; - $payload['replay_blocked_reason'] = 'activity_retry_policy_mismatch'; - $payload['replay_blocked_workflow_sequence'] = $throwable->workflowSequence; - $payload['replay_blocked_policy_field'] = $throwable->policyField; - $task->forceFill([ - 'status' => TaskStatus::Failed, - 'payload' => $payload, - 'last_error' => $throwable->getMessage(), - 'lease_expires_at' => null, - ])->save(); - $this->projectRun( - $run->fresh(['instance', 'tasks', 'activityExecutions', 'timers', 'failures', 'historyEvents']) - ); + $this->blockReplayUntilCompatibleActivityRetryPolicy($run, $task, $throwable); return; } @@ -4519,6 +4517,27 @@ private function ensureTypedStepHistoryRecorded( } } + private function blockReplayUntilCompatibleActivityRetryPolicy( + WorkflowRun $run, + WorkflowTask $task, + ActivityRetryPolicyMismatchException $throwable, + ): void { + $payload = is_array($task->payload) ? $task->payload : []; + $payload['replay_blocked'] = true; + $payload['replay_blocked_reason'] = 'activity_retry_policy_mismatch'; + $payload['replay_blocked_workflow_sequence'] = $throwable->workflowSequence; + $payload['replay_blocked_policy_field'] = $throwable->policyField; + $task->forceFill([ + 'status' => TaskStatus::Failed, + 'payload' => $payload, + 'last_error' => $throwable->getMessage(), + 'lease_expires_at' => null, + ])->save(); + $this->projectRun( + $run->fresh(['instance', 'tasks', 'activityExecutions', 'timers', 'failures', 'historyEvents']) + ); + } + private function blockReplayUntilCompatibleVersionRange( WorkflowRun $run, WorkflowTask $task, @@ -4854,6 +4873,7 @@ private function startChildRetryIfAvailable( $retryRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $failedChildRun->workflow_instance_id, 'run_number' => $nextRunNumber, + 'observability_context' => TraceCarrier::fromRun($failedChildRun), 'workflow_class' => $failedChildRun->workflow_class, 'workflow_type' => $failedChildRun->workflow_type, 'namespace' => $failedChildRun->namespace, @@ -5258,6 +5278,7 @@ private function recordClosedParallelChildResolutions( private function activityResult(WorkflowHistoryEvent $event, ?WorkflowRun $run = null): mixed { + ExecutionTracing::linkOutcome($run, $event); $serialized = ExternalPayloads::payloadBlob( $event->payload['result'] ?? null, $this->stringValue($event->payload['payload_codec'] ?? null) ?? $this->stringValue( @@ -5331,6 +5352,8 @@ private function activityCompletionEvent(WorkflowRun $run, int $sequence): ?Work ) && ($event->payload['sequence'] ?? null) === $sequence ); + ExecutionTracing::linkOutcome($run, $event); + return $event; } @@ -5743,6 +5766,9 @@ private function deliverCancellationAtCall( $operationSequence, $operationSequenceSpan, ); + if (ExecutionTracing::enabled()) { + ExecutionTracing::linkCancellation($run); + } return CancellationDeliveryState::Delivered; } @@ -5828,6 +5854,8 @@ private function timerFiredEvent(WorkflowRun $run, int $sequence): ?WorkflowHist && ($event->payload['sequence'] ?? null) === $sequence ); + ExecutionTracing::linkOutcome($run, $event); + return $event; } @@ -6461,6 +6489,9 @@ private function applyPendingUpdates( ]), new ReflectionMethod($workflow, $resolvedTarget['method']), ); + if ($command instanceof WorkflowCommand && ExecutionTracing::enabled()) { + ExecutionTracing::linkUpdate($run, $command); + } $result = $workflow->{$resolvedTarget['method']}(...$parameters); $appliedEvent = WorkflowHistoryEvent::record($run, HistoryEventType::UpdateApplied, [ diff --git a/src/V2/Tracing/LogContext.php b/src/V2/Tracing/LogContext.php new file mode 100644 index 000000000..490bb18ce --- /dev/null +++ b/src/V2/Tracing/LogContext.php @@ -0,0 +1,46 @@ +getLogger(); + if (method_exists($target, 'pushProcessor')) { + $target->pushProcessor($this->process(...)); + } + } + + public function process(array|LogRecord $record): array|LogRecord + { + try { + $fields = ExecutionTracing::current(); + if ($fields === []) { + return $record; + } + $context = $record instanceof LogRecord ? $record->context : $record['context']; + if (array_key_exists('request_id', $context)) { + unset($fields['request_id']); + } + $context = $fields + $context; + + if ($record instanceof LogRecord) { + return $record->with(context: $context); + } + $record['context'] = $context; + } catch (Throwable) { + // A logging adapter must not disrupt the application's logging path. + } + + return $record; + } +} diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index b4277d4ce..04c1a54ce 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Carbon\CarbonImmutable; +use Carbon\CarbonInterface; use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; @@ -57,6 +58,7 @@ use Workflow\V2\Support\ChildRunHistory; use Workflow\V2\Support\ConfiguredV2Models; use Workflow\V2\Support\CurrentRunResolver; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ExternalPayloads; use Workflow\V2\Support\LifecycleEventDispatcher; use Workflow\V2\Support\MemoUpsertService; @@ -78,6 +80,7 @@ use Workflow\V2\Support\TaskDispatcher; use Workflow\V2\Support\TaskRepair; use Workflow\V2\Support\TimerCancellation; +use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\TypeRegistry; use Workflow\V2\Support\UpdateCommandGate; use Workflow\V2\Support\UpdateRequestIdentity; @@ -940,274 +943,25 @@ public function start(...$arguments): StartResult public function attemptStart(...$arguments): StartResult { - /** @var WorkflowCommand|null $command */ - $command = null; - [$arguments, $startOptions] = $this->extractStartArguments($arguments); $metadata = WorkflowMetadata::fromStartArguments($arguments); - $task = null; - $startedRun = null; - - SqliteTransaction::run(function () use ($metadata, $startOptions, &$task, &$command, &$startedRun): void { - $task = null; - $command = null; - $startedRun = null; - - /** @var WorkflowInstance $instance */ - $instance = self::instanceQuery() - ->lockForUpdate() - ->findOrFail($this->instance->id); - - $run = $this->currentRunForInstance($instance, true); - - if ($run instanceof WorkflowRun) { - $canReturnExisting = $startOptions->duplicateStartPolicy === DuplicateStartPolicy::ReturnExistingActive - && in_array($run->status, [RunStatus::Pending, RunStatus::Running, RunStatus::Waiting], true); - - /** @var WorkflowCommand $command */ - $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ - 'command_type' => CommandType::Start->value, - 'target_scope' => 'instance', - 'status' => $canReturnExisting - ? CommandStatus::Accepted->value - : CommandStatus::Rejected->value, - 'outcome' => $canReturnExisting - ? CommandOutcome::ReturnedExistingActive->value - : CommandOutcome::RejectedDuplicate->value, - 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), - 'payload' => Serializer::serializeWithCodec( - $run->payload_codec ?? CodecRegistry::defaultCodec(), - $metadata->arguments - ), - 'rejection_reason' => $canReturnExisting - ? null - : 'instance_already_started', - 'accepted_at' => $canReturnExisting - ? now() - : null, - 'applied_at' => $canReturnExisting - ? now() - : null, - 'rejected_at' => $canReturnExisting - ? null - : now(), - ])); - - WorkflowHistoryEvent::record($run, $canReturnExisting - ? HistoryEventType::StartAccepted - : HistoryEventType::StartRejected, [ - 'workflow_command_id' => $command->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => AvroValueJsonProjection::project($run->typedMemos()), - 'search_attributes' => $run->typedSearchAttributes(), - 'outcome' => $command->outcome?->value, - 'rejection_reason' => $command->rejection_reason, - ], null, $command); - - self::projectRun($run, self::PROJECTION_RUN_RELATIONS); - - return; - } - - $workflowClass = TypeRegistry::resolveWorkflowClass($instance->workflow_class, $instance->workflow_type); - $commandContract = RunCommandContract::snapshot($workflowClass); - $businessKey = $startOptions->businessKey ?? $instance->business_key; - $visibilityLabels = $startOptions->labels !== [] - ? $startOptions->labels - : (is_array($instance->visibility_labels) ? $instance->visibility_labels : null); - $memo = $startOptions->memo !== [] - ? $startOptions->memo - : (is_array($instance->memo) ? $instance->memo : null); - $searchAttributes = $startOptions->searchAttributes !== [] - ? $startOptions->searchAttributes - : null; - $executionTimeoutSeconds = $startOptions->executionTimeoutSeconds; - $runTimeoutSeconds = $startOptions->runTimeoutSeconds; - $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); - $queue = RoutingResolver::workflowQueue($workflowClass, $metadata); - $startBlockedReason = WorkflowStartGate::blockedReason( - WorkerCompatibility::current(), - $connection, - $queue, - ); - - if ($startBlockedReason !== null) { - $command = $this->rejectStartCommand( - $instance, - $metadata->arguments, - $startBlockedReason, - WorkflowStartGate::blockedMessage( - sprintf('Workflow instance [%s] cannot start.', $instance->id), - WorkerCompatibility::current(), - $connection, - $queue, - ) ?? sprintf('Workflow instance [%s] cannot start.', $instance->id), - ); - - return; - } - - if ($instance->workflow_class !== $workflowClass) { - $instance->forceFill([ - 'workflow_class' => $workflowClass, - ])->save(); - } - - $startedAt = now(); - $executionDeadlineAt = $executionTimeoutSeconds !== null - ? $startedAt->copy() - ->addSeconds($executionTimeoutSeconds) - : null; - $runDeadlineAt = $runTimeoutSeconds !== null - ? $startedAt->copy() - ->addSeconds($runTimeoutSeconds) - : null; - $payloadCodec = CodecRegistry::defaultCodec(); - $serializedArguments = ExternalPayloads::externalizeForNamespace( - Serializer::serializeWithCodec($payloadCodec, $metadata->arguments), - $payloadCodec, - is_string($instance->namespace) ? $instance->namespace : null, - ); - - /** @var WorkflowRun $run */ - $run = self::runQuery()->create([ - 'workflow_instance_id' => $instance->id, - 'run_number' => $instance->run_count + 1, - 'workflow_class' => $workflowClass, - 'workflow_type' => $instance->workflow_type, - 'namespace' => $instance->namespace, - 'business_key' => $businessKey, - 'visibility_labels' => $visibilityLabels, - 'run_timeout_seconds' => $runTimeoutSeconds, - 'execution_deadline_at' => $executionDeadlineAt, - 'run_deadline_at' => $runDeadlineAt, - 'status' => RunStatus::Pending->value, - 'compatibility' => WorkerCompatibility::current(), - 'payload_codec' => $payloadCodec, - 'arguments' => $serializedArguments, - 'connection' => $connection, - 'queue' => $queue, - 'started_at' => $startedAt, - 'last_progress_at' => $startedAt, - 'last_history_sequence' => 0, - ]); - - $this->seedTypedVisibilityMetadata($run, $memo, $searchAttributes); - - /** @var WorkflowCommand $command */ - $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ - 'command_type' => CommandType::Start->value, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::StartedNew->value, - 'payload_codec' => $payloadCodec, - 'payload' => $serializedArguments, - 'accepted_at' => now(), - 'applied_at' => now(), - ])); - - $instance->forceFill([ - 'current_run_id' => $run->id, - 'business_key' => $businessKey, - 'visibility_labels' => $visibilityLabels, - 'memo' => $memo, - 'execution_timeout_seconds' => $executionTimeoutSeconds, - 'started_at' => $startedAt, - 'run_count' => $run->run_number, - ])->save(); - - $memoPayload = self::visibilityMetadataPayload($memo); - $searchAttributesPayload = self::visibilityMetadataPayload($searchAttributes); - - WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ - 'workflow_command_id' => $command->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => $memoPayload, - 'search_attributes' => $searchAttributesPayload, - 'outcome' => $command->outcome?->value, - ], null, $command); - - WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowStarted, [ - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_command_id' => $command->id, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => $memoPayload, - 'search_attributes' => $searchAttributesPayload, - 'execution_timeout_seconds' => $executionTimeoutSeconds, - 'run_timeout_seconds' => $runTimeoutSeconds, - 'execution_deadline_at' => $executionDeadlineAt?->toIso8601String(), - 'run_deadline_at' => $runDeadlineAt?->toIso8601String(), - 'workflow_definition_fingerprint' => WorkflowDefinition::fingerprint($workflowClass), - 'declared_queries' => $commandContract['queries'], - 'declared_query_contracts' => $commandContract['query_contracts'], - 'declared_signals' => $commandContract['signals'], - 'declared_signal_contracts' => $commandContract['signal_contracts'], - 'declared_updates' => $commandContract['updates'], - 'declared_update_contracts' => $commandContract['update_contracts'], - 'declared_entry_method' => $commandContract['entry_method'], - 'declared_entry_mode' => $commandContract['entry_mode'], - 'declared_entry_declaring_class' => $commandContract['entry_declaring_class'], - ], null, $command); - - /** @var WorkflowTask $task */ - $task = self::taskQuery()->create([ - 'workflow_run_id' => $run->id, - 'namespace' => $run->namespace, - 'task_type' => TaskType::Workflow->value, - 'status' => TaskStatus::Ready->value, - 'available_at' => now(), - 'payload' => [], - 'connection' => $run->connection, - 'queue' => $run->queue, - 'compatibility' => $run->compatibility, - ]); - - self::projectRun($run, ['instance', 'tasks', 'activityExecutions', 'failures', 'historyEvents']); - - $startedRun = $run; - }, self::storageTransactionAttempts(), admission: function (): void { - $instance = self::instanceQuery(); - $key = $instance->getModel() - ->getKeyName(); - $instance->whereKey($this->instance->id) - ->toBase() - ->update([ - $key => $this->instance->id, - ]); - }); - - if ($startedRun instanceof WorkflowRun) { - LifecycleEventDispatcher::workflowStarted($startedRun); - } - - $this->refresh(); - - if ($task instanceof WorkflowTask) { - TaskDispatcher::dispatch($task); + // Capture outside the storage transaction and preserve it across its retries. + $observabilityContext = null; + $diagnostic = null; + $tracing = ExecutionTracing::enabled(); + if ($tracing) { + $observabilityContext = TraceCarrier::admissionParent($diagnostic); } - - if (! $command instanceof WorkflowCommand) { - throw new LogicException(sprintf( - 'Workflow instance [%s] failed to record a start command.', - $this->instance->id - )); + if (! $tracing) { + return $this->admitStart($metadata, $startOptions, $observabilityContext); } - return StartResult::fromCommand($command); + return ExecutionTracing::admission( + $this->instance, + fn () => $this->admitStart($metadata, $startOptions, $observabilityContext), + enabled: true, + diagnostic: $diagnostic + ); } public function signalWithStart( @@ -1913,80 +1667,453 @@ public function attemptArchive(?string $reason = null): CommandResult return new CommandResult($command); } - /** - * @param array{scope_id: string, preparation_history_event_id: string, child_call_id: string}|null $scopeTarget - */ - private function recordCancellationRequest( - ?string $reason, - int $cleanupTimeoutSeconds, - ?string $parentWorkflowRunId = null, - ?array $scopeTarget = null, - ?string $serviceCallId = null, - ): CommandResult { - if ($cleanupTimeoutSeconds < 1 || $cleanupTimeoutSeconds > 3600) { - throw new LogicException('Cancellation cleanup timeout must be between 1 and 3600 seconds.'); - } - + private function admitStart( + WorkflowMetadata $metadata, + StartOptions $startOptions, + ?array $observabilityContext, + ): StartResult { /** @var WorkflowCommand|null $command */ $command = null; $task = null; + $startedRun = null; - DB::transaction(function () use ( - &$command, + SqliteTransaction::run(function () use ( + $metadata, + $startOptions, + $observabilityContext, &$task, - $reason, - $cleanupTimeoutSeconds, - $parentWorkflowRunId, - $scopeTarget, - $serviceCallId + &$command, + &$startedRun, ): void { - $command = null; $task = null; - // Scoped actors hold the parent prefix before touching a child instance. - /** @var WorkflowRun|null $scopeParent */ - $scopeParent = $scopeTarget === null ? null : self::runQuery()->lockForUpdate()->findOrFail( - $parentWorkflowRunId - ); + $command = null; + $startedRun = null; + /** @var WorkflowInstance $instance */ - $instance = self::instanceQuery()->lockForUpdate()->findOrFail($this->instance->id); - $currentRun = $this->currentRunForInstance($instance, true); + $instance = self::instanceQuery() + ->lockForUpdate() + ->findOrFail($this->instance->id); - if (! $currentRun instanceof WorkflowRun) { - $command = $this->rejectCommand( - $instance, - null, - CommandType::RequestCancellation, - 'instance_not_started', - $this->commandTargetScope(), - ); + $run = $this->currentRunForInstance($instance, true); + + if ($run instanceof WorkflowRun) { + $command = $this->recordExistingStart($instance, $run, $metadata, $startOptions); return; } - if ($this->runTargeted) { - /** @var WorkflowRun $run */ - $run = self::runQuery()->lockForUpdate()->findOrFail($this->selectedRunId); - - if ($run->id !== $currentRun->id) { - $command = $this->rejectCommand( - $instance, - $run, - CommandType::RequestCancellation, - 'selected_run_not_current', - $this->commandTargetScope(), - [ - 'resolved_workflow_run_id' => $currentRun->id, - ], - ); - - return; - } - } else { - $run = $currentRun; + $workflowClass = TypeRegistry::resolveWorkflowClass($instance->workflow_class, $instance->workflow_type); + $commandContract = RunCommandContract::snapshot($workflowClass); + [$businessKey, $visibilityLabels, $memo, $searchAttributes] = $this->initialVisibilityMetadata( + $instance, + $startOptions, + ); + $executionTimeoutSeconds = $startOptions->executionTimeoutSeconds; + $runTimeoutSeconds = $startOptions->runTimeoutSeconds; + $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); + $queue = RoutingResolver::workflowQueue($workflowClass, $metadata); + $command = $this->rejectIncompatibleStart($instance, $metadata->arguments, $connection, $queue); + if ($command instanceof WorkflowCommand) { + return; } - $parentContext = null; - $scopeOrigin = null; + if ($instance->workflow_class !== $workflowClass) { + $instance->forceFill([ + 'workflow_class' => $workflowClass, + ])->save(); + } + + $startedAt = now(); + $executionDeadlineAt = $executionTimeoutSeconds !== null + ? $startedAt->copy() + ->addSeconds($executionTimeoutSeconds) + : null; + $runDeadlineAt = $runTimeoutSeconds !== null + ? $startedAt->copy() + ->addSeconds($runTimeoutSeconds) + : null; + $payloadCodec = CodecRegistry::defaultCodec(); + $serializedArguments = ExternalPayloads::externalizeForNamespace( + Serializer::serializeWithCodec($payloadCodec, $metadata->arguments), + $payloadCodec, + is_string($instance->namespace) ? $instance->namespace : null, + ); + + /** @var WorkflowRun $run */ + $run = self::runQuery()->create([ + 'workflow_instance_id' => $instance->id, + 'run_number' => $instance->run_count + 1, + 'observability_context' => $observabilityContext, + 'workflow_class' => $workflowClass, + 'workflow_type' => $instance->workflow_type, + 'namespace' => $instance->namespace, + 'business_key' => $businessKey, + 'visibility_labels' => $visibilityLabels, + 'run_timeout_seconds' => $runTimeoutSeconds, + 'execution_deadline_at' => $executionDeadlineAt, + 'run_deadline_at' => $runDeadlineAt, + 'status' => RunStatus::Pending->value, + 'compatibility' => WorkerCompatibility::current(), + 'payload_codec' => $payloadCodec, + 'arguments' => $serializedArguments, + 'connection' => $connection, + 'queue' => $queue, + 'started_at' => $startedAt, + 'last_progress_at' => $startedAt, + 'last_history_sequence' => 0, + ]); + + $this->seedTypedVisibilityMetadata($run, $memo, $searchAttributes); + + $command = $this->recordNewStartCommand($instance, $run, $payloadCodec, $serializedArguments); + + $instance->forceFill([ + 'current_run_id' => $run->id, + 'business_key' => $businessKey, + 'visibility_labels' => $visibilityLabels, + 'memo' => $memo, + 'execution_timeout_seconds' => $executionTimeoutSeconds, + 'started_at' => $startedAt, + 'run_count' => $run->run_number, + ])->save(); + + $memoPayload = self::visibilityMetadataPayload($memo); + $searchAttributesPayload = self::visibilityMetadataPayload($searchAttributes); + + $this->recordInitialStartAccepted($instance, $run, $command, $memoPayload, $searchAttributesPayload); + $this->recordWorkflowStartedHistory($instance, $run, $command, $workflowClass, $commandContract, [ + 'memo' => $memoPayload, + 'search_attributes' => $searchAttributesPayload, + 'execution_timeout_seconds' => $executionTimeoutSeconds, + 'run_timeout_seconds' => $runTimeoutSeconds, + 'execution_deadline_at' => $executionDeadlineAt, + 'run_deadline_at' => $runDeadlineAt, + ]); + + $task = $this->createInitialWorkflowTask($run); + + self::projectRun($run, ['instance', 'tasks', 'activityExecutions', 'failures', 'historyEvents']); + + $startedRun = $run; + }, self::storageTransactionAttempts(), admission: function (): void { + $this->acquireInstanceWriter(); + }); + + if ($startedRun instanceof WorkflowRun) { + LifecycleEventDispatcher::workflowStarted($startedRun); + } + + $this->refresh(); + + if ($task instanceof WorkflowTask) { + TaskDispatcher::dispatch($task); + } + + if (! $command instanceof WorkflowCommand) { + throw new LogicException(sprintf( + 'Workflow instance [%s] failed to record a start command.', + $this->instance->id + )); + } + + return StartResult::fromCommand($command); + } + + private function recordExistingStart( + WorkflowInstance $instance, + WorkflowRun $run, + WorkflowMetadata $metadata, + StartOptions $startOptions, + ): WorkflowCommand { + $canReturnExisting = $startOptions->duplicateStartPolicy === DuplicateStartPolicy::ReturnExistingActive + && in_array($run->status, [RunStatus::Pending, RunStatus::Running, RunStatus::Waiting], true); + + $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ + 'command_type' => CommandType::Start->value, + 'target_scope' => 'instance', + 'status' => $canReturnExisting + ? CommandStatus::Accepted->value + : CommandStatus::Rejected->value, + 'outcome' => $canReturnExisting + ? CommandOutcome::ReturnedExistingActive->value + : CommandOutcome::RejectedDuplicate->value, + 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), + 'payload' => Serializer::serializeWithCodec( + $run->payload_codec ?? CodecRegistry::defaultCodec(), + $metadata->arguments + ), + 'rejection_reason' => $canReturnExisting + ? null + : 'instance_already_started', + 'accepted_at' => $canReturnExisting + ? now() + : null, + 'applied_at' => $canReturnExisting + ? now() + : null, + 'rejected_at' => $canReturnExisting + ? null + : now(), + ])); + + WorkflowHistoryEvent::record($run, $canReturnExisting + ? HistoryEventType::StartAccepted + : HistoryEventType::StartRejected, [ + 'workflow_command_id' => $command->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => AvroValueJsonProjection::project($run->typedMemos()), + 'search_attributes' => $run->typedSearchAttributes(), + 'outcome' => $command->outcome?->value, + 'rejection_reason' => $command->rejection_reason, + ], null, $command); + + self::projectRun($run, self::PROJECTION_RUN_RELATIONS); + + return $command; + } + + /** + * @return array{mixed, array|null, array|null, array|null} + */ + private function initialVisibilityMetadata(WorkflowInstance $instance, StartOptions $startOptions): array + { + $businessKey = $startOptions->businessKey ?? $instance->business_key; + $visibilityLabels = $startOptions->labels !== [] + ? $startOptions->labels + : (is_array($instance->visibility_labels) ? $instance->visibility_labels : null); + $memo = $startOptions->memo !== [] + ? $startOptions->memo + : (is_array($instance->memo) ? $instance->memo : null); + $searchAttributes = $startOptions->searchAttributes !== [] + ? $startOptions->searchAttributes + : null; + + return [$businessKey, $visibilityLabels, $memo, $searchAttributes]; + } + + private function createInitialWorkflowTask(WorkflowRun $run): WorkflowTask + { + return self::taskQuery()->create([ + 'workflow_run_id' => $run->id, + 'namespace' => $run->namespace, + 'task_type' => TaskType::Workflow->value, + 'status' => TaskStatus::Ready->value, + 'available_at' => now(), + 'payload' => [], + 'connection' => $run->connection, + 'queue' => $run->queue, + 'compatibility' => $run->compatibility, + ]); + } + + private function acquireInstanceWriter(): void + { + $instance = self::instanceQuery(); + $key = $instance->getModel() + ->getKeyName(); + $instance->whereKey($this->instance->id) + ->toBase() + ->update([ + $key => $this->instance->id, + ]); + } + + private function rejectIncompatibleStart( + WorkflowInstance $instance, + array $arguments, + ?string $connection, + ?string $queue, + ): ?WorkflowCommand { + $reason = WorkflowStartGate::blockedReason(WorkerCompatibility::current(), $connection, $queue); + if ($reason === null) { + return null; + } + + return $this->rejectStartCommand( + $instance, + $arguments, + $reason, + WorkflowStartGate::blockedMessage( + sprintf('Workflow instance [%s] cannot start.', $instance->id), + WorkerCompatibility::current(), + $connection, + $queue, + ) ?? sprintf('Workflow instance [%s] cannot start.', $instance->id), + ); + } + + private function recordInitialStartAccepted( + WorkflowInstance $instance, + WorkflowRun $run, + WorkflowCommand $command, + ?array $memo, + ?array $searchAttributes, + ): void { + WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ + 'workflow_command_id' => $command->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => $memo, + 'search_attributes' => $searchAttributes, + 'outcome' => $command->outcome?->value, + ], null, $command); + } + + private function recordNewStartCommand( + WorkflowInstance $instance, + WorkflowRun $run, + string $payloadCodec, + string $arguments, + ?CommandContext $commandContext = null, + ): WorkflowCommand { + $attributes = [ + 'command_type' => CommandType::Start->value, + 'target_scope' => 'instance', + 'status' => CommandStatus::Accepted->value, + 'outcome' => CommandOutcome::StartedNew->value, + 'payload_codec' => $payloadCodec, + 'payload' => $arguments, + 'accepted_at' => now(), + 'applied_at' => now(), + ]; + + return WorkflowCommand::record($instance, $run, $commandContext === null + ? $this->commandAttributes($attributes) + : $this->commandAttributesForContext($commandContext, $attributes)); + } + + /** + * @param array $commandContract + * @param array{ + * memo: array|null, + * search_attributes: array|null, + * execution_timeout_seconds: int|null, + * run_timeout_seconds: int|null, + * execution_deadline_at: CarbonInterface|null, + * run_deadline_at: CarbonInterface|null + * } $startMetadata + */ + private function recordWorkflowStartedHistory( + WorkflowInstance $instance, + WorkflowRun $run, + WorkflowCommand $command, + string $workflowClass, + array $commandContract, + array $startMetadata, + ): void { + WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowStarted, [ + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'workflow_command_id' => $command->id, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => $startMetadata['memo'], + 'search_attributes' => $startMetadata['search_attributes'], + 'execution_timeout_seconds' => $startMetadata['execution_timeout_seconds'], + 'run_timeout_seconds' => $startMetadata['run_timeout_seconds'], + 'execution_deadline_at' => $startMetadata['execution_deadline_at']?->toIso8601String(), + 'run_deadline_at' => $startMetadata['run_deadline_at']?->toIso8601String(), + 'workflow_definition_fingerprint' => WorkflowDefinition::fingerprint($workflowClass), + 'declared_queries' => $commandContract['queries'], + 'declared_query_contracts' => $commandContract['query_contracts'], + 'declared_signals' => $commandContract['signals'], + 'declared_signal_contracts' => $commandContract['signal_contracts'], + 'declared_updates' => $commandContract['updates'], + 'declared_update_contracts' => $commandContract['update_contracts'], + 'declared_entry_method' => $commandContract['entry_method'], + 'declared_entry_mode' => $commandContract['entry_mode'], + 'declared_entry_declaring_class' => $commandContract['entry_declaring_class'], + ], null, $command); + } + + /** + * @param array{scope_id: string, preparation_history_event_id: string, child_call_id: string}|null $scopeTarget + */ + private function recordCancellationRequest( + ?string $reason, + int $cleanupTimeoutSeconds, + ?string $parentWorkflowRunId = null, + ?array $scopeTarget = null, + ?string $serviceCallId = null, + ): CommandResult { + if ($cleanupTimeoutSeconds < 1 || $cleanupTimeoutSeconds > 3600) { + throw new LogicException('Cancellation cleanup timeout must be between 1 and 3600 seconds.'); + } + + $incomingContext = $parentWorkflowRunId === null && $scopeTarget === null && $serviceCallId === null + ? TraceCarrier::captureConfigured() : null; + /** @var WorkflowCommand|null $command */ + $command = null; + $task = null; + + DB::transaction(function () use ( + &$command, + &$task, + $reason, + $cleanupTimeoutSeconds, + $parentWorkflowRunId, + $scopeTarget, + $serviceCallId, + $incomingContext + ): void { + $command = null; + $task = null; + // Scoped actors hold the parent prefix before touching a child instance. + /** @var WorkflowRun|null $scopeParent */ + $scopeParent = $scopeTarget === null ? null : self::runQuery()->lockForUpdate()->findOrFail( + $parentWorkflowRunId + ); + /** @var WorkflowInstance $instance */ + $instance = self::instanceQuery()->lockForUpdate()->findOrFail($this->instance->id); + $currentRun = $this->currentRunForInstance($instance, true); + + if (! $currentRun instanceof WorkflowRun) { + $command = $this->rejectCommand( + $instance, + null, + CommandType::RequestCancellation, + 'instance_not_started', + $this->commandTargetScope(), + ); + + return; + } + + if ($this->runTargeted) { + /** @var WorkflowRun $run */ + $run = self::runQuery()->lockForUpdate()->findOrFail($this->selectedRunId); + + if ($run->id !== $currentRun->id) { + $command = $this->rejectCommand( + $instance, + $run, + CommandType::RequestCancellation, + 'selected_run_not_current', + $this->commandTargetScope(), + [ + 'resolved_workflow_run_id' => $currentRun->id, + ], + ); + + return; + } + } else { + $run = $currentRun; + } + + $parentContext = null; + $scopeOrigin = null; $scopePreparation = null; $scopeDeadline = null; if ($scopeParent !== null && $scopeTarget !== null) { @@ -2210,6 +2337,7 @@ private function recordCancellationRequest( 'target_scope' => $this->commandTargetScope(), 'status' => CommandStatus::Accepted->value, 'outcome' => CommandOutcome::CancellationRequested->value, + 'observability_context' => $incomingContext, 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), 'payload' => Serializer::serializeWithCodec( $run->payload_codec ?? CodecRegistry::defaultCodec(), @@ -2289,6 +2417,7 @@ private function recordCancellationRequest( private function recordAcceptedUpdateWithArguments(string $method, array $arguments): array { $this->refresh(); + $incomingContext = TraceCarrier::captureConfigured(); /** @var WorkflowCommand|null $command */ $command = null; @@ -2297,10 +2426,17 @@ private function recordAcceptedUpdateWithArguments(string $method, array $argume /** @var WorkflowTask|null $resumeTask */ $resumeTask = null; - DB::transaction(function () use ($method, $arguments, &$command, &$update, &$resumeTask): void { - /** @var WorkflowInstance $instance */ - $instance = self::instanceQuery() - ->lockForUpdate() + DB::transaction(function () use ( + $method, + $arguments, + &$command, + &$update, + &$resumeTask, + $incomingContext + ): void { + /** @var WorkflowInstance $instance */ + $instance = self::instanceQuery() + ->lockForUpdate() ->findOrFail($this->instance->id); [$existingCommand, $existingUpdate] = $this->existingUpdateForRequest($instance); @@ -2494,6 +2630,7 @@ private function recordAcceptedUpdateWithArguments(string $method, array $argume 'target_scope' => $this->commandTargetScope(), 'status' => CommandStatus::Accepted->value, 'accepted_at' => now(), + 'observability_context' => $incomingContext, ], $updateCommandAttributes))); /** @var WorkflowUpdate $update */ @@ -2723,6 +2860,7 @@ private function attemptSignalInternal( /** @var WorkflowCommand|null $command */ $command = null; $task = null; + $observabilityContext = $runtimeReserved ? null : TraceCarrier::captureConfigured(); SqliteTransaction::run(function () use ( $name, @@ -2730,6 +2868,7 @@ private function attemptSignalInternal( $payloadCodec, $payloadBlob, $runtimeReserved, + $observabilityContext, &$command, &$task ): void { @@ -2741,65 +2880,12 @@ private function attemptSignalInternal( ->lockForUpdate() ->findOrFail($this->instance->id); - $currentRun = $this->currentRunForInstance($instance, true); - - if (! $currentRun instanceof WorkflowRun) { - $command = $this->rejectCommand( - $instance, - null, - CommandType::Signal, - 'instance_not_started', - $this->commandTargetScope(), - $this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), - ); - $this->recordRejectedSignal($command, $name, $arguments); - - return; - } - - if ($this->runTargeted) { - /** @var WorkflowRun $run */ - $run = self::runQuery() - ->lockForUpdate() - ->findOrFail($this->selectedRunId); - - if ($run->id !== $currentRun->id) { - $command = $this->rejectCommand( - $instance, - $run, - CommandType::Signal, - 'selected_run_not_current', - $this->commandTargetScope(), - array_merge($this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), [ - 'resolved_workflow_run_id' => $currentRun->id, - ]), - ); - $this->recordRejectedSignal($command, $name, $arguments); - - return; - } - } else { - $run = $currentRun; - } - - if (in_array($run->status, [ - RunStatus::Completed, - RunStatus::Failed, - RunStatus::Cancelled, - RunStatus::Terminated, - ], true)) { - $command = $this->rejectCommand( - $instance, - $run, - CommandType::Signal, - 'run_not_active', - $this->commandTargetScope(), - $this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), - ); - $this->recordRejectedSignal($command, $name, $arguments); - + $target = $this->signalRunOrRejection($instance, $name, $arguments, $payloadCodec); + if ($target instanceof WorkflowCommand) { + $command = $target; return; } + $run = $target; $projectionRole = self::historyProjectionRole(); $bufferedSignalSummary = null; @@ -2889,56 +2975,20 @@ private function attemptSignalInternal( ); $serializedSignalArguments = $payloadBlob ?? Serializer::serializeWithCodec($signalCodec, $arguments); - StructuralLimits::logWarning( - StructuralLimits::warnApproachingPayloadSize($serializedSignalArguments), - [ - 'workflow_run_id' => $run->id, - 'workflow_type' => $run->workflow_type, - 'payload_site' => 'signal_input', - 'signal_name' => $name, - ], - ); - - try { - StructuralLimits::guardPayloadSize($serializedSignalArguments); - } catch (StructuralLimitExceededException $e) { - $command = $this->rejectCommand( - $instance, - $run, - CommandType::Signal, - 'structural_limit_exceeded', - $this->commandTargetScope(), - $this->structuralLimitExceededCommandPayloadAttributes($name, $signalCodec, $e), - ); - $this->recordRejectedSignal($command, $name, [], [ - 'arguments' => [$e->getMessage()], - ]); - + $command = $this->rejectOversizedSignal($instance, $run, $name, $signalCodec, $serializedSignalArguments); + if ($command instanceof WorkflowCommand) { return; } - $storedExternally = $payloadBlob !== null && ExternalPayloads::isStoredReference($payloadBlob); - $commandAttributes = $this->commandAttributes([ - 'command_type' => CommandType::Signal->value, - 'target_scope' => $this->commandTargetScope(), - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::SignalReceived->value, - ...($storedExternally - ? [ - 'payload_codec' => $signalCodec, - 'payload' => $payloadBlob, - ] - : $this->signalCommandPayloadAttributes($name, $arguments, [], $signalCodec)), - 'accepted_at' => now(), - ]); - if ($storedExternally) { - $commandAttributes['context'] = array_merge($commandAttributes['context'], [ - 'signal_name' => $name, - ]); - } - - /** @var WorkflowCommand $command */ - $command = WorkflowCommand::record($instance, $run, $commandAttributes); + $command = $this->recordAcceptedSignalCommand( + $instance, + $run, + $name, + $arguments, + $signalCodec, + $payloadBlob, + $observabilityContext, + ); $signalWaitId = $bufferedSignalSummary instanceof WorkflowRunSummary ? SignalWaits::bufferedWaitIdForCommandId($command->id) @@ -2954,20 +3004,7 @@ private function attemptSignalInternal( $serializedSignalArguments, ); - $event = WorkflowHistoryEvent::record($run, HistoryEventType::SignalReceived, array_filter([ - 'workflow_command_id' => $command->id, - 'signal_id' => $signal->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'signal_name' => $name, - 'signal_wait_id' => $signalWaitId, - 'arguments' => ExternalPayloads::historyValue( - is_string($signal->arguments) ? $signal->arguments : null, - is_string($signal->payload_codec) ? $signal->payload_codec : null, - is_string($run->namespace) ? $run->namespace : null, - ), - 'payload_codec' => $signal->payload_codec, - ], static fn (mixed $value): bool => $value !== null), null, $command); + $event = $this->recordSignalReceivedHistory($instance, $run, $command, $signal, $name, $signalWaitId); if (! $bufferedSignalSummary instanceof WorkflowRunSummary && ! $this->hasOpenWorkflowTask($run->id)) { $task = PendingMessageTask::createForRun($run); @@ -2982,14 +3019,7 @@ private function attemptSignalInternal( self::projectRun($run, self::PROJECTION_RUN_RELATIONS); } }, self::mysqlCommandTransactionAttempts(), admission: function (): void { - $instance = self::instanceQuery(); - $key = $instance->getModel() - ->getKeyName(); - $instance->whereKey($this->instance->id) - ->toBase() - ->update([ - $key => $this->instance->id, - ]); + $this->acquireInstanceWriter(); }); $this->refresh(); @@ -3014,6 +3044,166 @@ private function attemptSignalInternal( return $result; } + private function signalRunOrRejection( + WorkflowInstance $instance, + string $name, + array $arguments, + ?string $payloadCodec, + ): WorkflowRun|WorkflowCommand { + $currentRun = $this->currentRunForInstance($instance, true); + if (! $currentRun instanceof WorkflowRun) { + $command = $this->rejectCommand( + $instance, + null, + CommandType::Signal, + 'instance_not_started', + $this->commandTargetScope(), + $this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), + ); + $this->recordRejectedSignal($command, $name, $arguments); + + return $command; + } + + if ($this->runTargeted) { + /** @var WorkflowRun $run */ + $run = self::runQuery() + ->lockForUpdate() + ->findOrFail($this->selectedRunId); + + if ($run->id !== $currentRun->id) { + $command = $this->rejectCommand( + $instance, + $run, + CommandType::Signal, + 'selected_run_not_current', + $this->commandTargetScope(), + array_merge($this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), [ + 'resolved_workflow_run_id' => $currentRun->id, + ]), + ); + $this->recordRejectedSignal($command, $name, $arguments); + + return $command; + } + } else { + $run = $currentRun; + } + + if (in_array($run->status, [ + RunStatus::Completed, + RunStatus::Failed, + RunStatus::Cancelled, + RunStatus::Terminated, + ], true)) { + $command = $this->rejectCommand( + $instance, + $run, + CommandType::Signal, + 'run_not_active', + $this->commandTargetScope(), + $this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), + ); + $this->recordRejectedSignal($command, $name, $arguments); + + return $command; + } + + return $run; + } + + private function recordSignalReceivedHistory( + WorkflowInstance $instance, + WorkflowRun $run, + WorkflowCommand $command, + WorkflowSignal $signal, + string $name, + ?string $signalWaitId, + ): WorkflowHistoryEvent { + return WorkflowHistoryEvent::record($run, HistoryEventType::SignalReceived, array_filter([ + 'workflow_command_id' => $command->id, + 'signal_id' => $signal->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'signal_name' => $name, + 'signal_wait_id' => $signalWaitId, + 'arguments' => ExternalPayloads::historyValue( + is_string($signal->arguments) ? $signal->arguments : null, + is_string($signal->payload_codec) ? $signal->payload_codec : null, + is_string($run->namespace) ? $run->namespace : null, + ), + 'payload_codec' => $signal->payload_codec, + ], static fn (mixed $value): bool => $value !== null), null, $command); + } + + private function recordAcceptedSignalCommand( + WorkflowInstance $instance, + WorkflowRun $run, + string $name, + array $arguments, + string $signalCodec, + ?string $payloadBlob, + ?array $observabilityContext, + ): WorkflowCommand { + $storedExternally = $payloadBlob !== null && ExternalPayloads::isStoredReference($payloadBlob); + $attributes = $this->commandAttributes([ + 'command_type' => CommandType::Signal->value, + 'observability_context' => $observabilityContext, + 'target_scope' => $this->commandTargetScope(), + 'status' => CommandStatus::Accepted->value, + 'outcome' => CommandOutcome::SignalReceived->value, + ...($storedExternally + ? [ + 'payload_codec' => $signalCodec, + 'payload' => $payloadBlob, + ] + : $this->signalCommandPayloadAttributes($name, $arguments, [], $signalCodec)), + 'accepted_at' => now(), + ]); + if ($storedExternally) { + $attributes['context'] = array_merge($attributes['context'], [ + 'signal_name' => $name, + ]); + } + + return WorkflowCommand::record($instance, $run, $attributes); + } + + private function rejectOversizedSignal( + WorkflowInstance $instance, + WorkflowRun $run, + string $name, + string $signalCodec, + string $arguments, + ): ?WorkflowCommand { + StructuralLimits::logWarning(StructuralLimits::warnApproachingPayloadSize($arguments), [ + 'workflow_run_id' => $run->id, + 'workflow_type' => $run->workflow_type, + 'payload_site' => 'signal_input', + 'signal_name' => $name, + ]); + + try { + StructuralLimits::guardPayloadSize($arguments); + } catch (StructuralLimitExceededException $e) { + $command = $this->rejectCommand( + $instance, + $run, + CommandType::Signal, + 'structural_limit_exceeded', + $this->commandTargetScope(), + $this->structuralLimitExceededCommandPayloadAttributes($name, $signalCodec, $e), + ); + $this->recordRejectedSignal($command, $name, [], [ + 'arguments' => [$e->getMessage()], + ]); + + return $command; + } + + return null; + } + /** * @param array $signalArguments * @param list $startArguments @@ -3030,13 +3220,43 @@ private function attemptSignalWithStartInternal( $this->refresh(); + $intakeGroupId = (string) Str::ulid(); + // Capture before locking. An existing run retains its original admission carrier. + $observabilityContext = null; + $diagnostic = null; + $tracing = ExecutionTracing::enabled(); + if ($tracing) { + $observabilityContext = TraceCarrier::admissionParent($diagnostic); + } + $operation = fn (): SignalWithStartResult => $this->admitSignalWithStart( + $name, + $signalArguments, + $startArguments, + $startOptions, + $intakeGroupId, + $observabilityContext, + ); + if (! $tracing) { + return $operation(); + } + + return ExecutionTracing::admission($this->instance, $operation, enabled: true, diagnostic: $diagnostic); + } + + private function admitSignalWithStart( + string $name, + array $signalArguments, + array $startArguments, + StartOptions $startOptions, + string $intakeGroupId, + ?array $observabilityContext, + ): SignalWithStartResult { /** @var WorkflowCommand|null $startCommand */ $startCommand = null; /** @var WorkflowCommand|null $signalCommand */ $signalCommand = null; $task = null; $startedRun = null; - $intakeGroupId = (string) Str::ulid(); DB::transaction(function () use ( $name, @@ -3044,6 +3264,7 @@ private function attemptSignalWithStartInternal( $startArguments, $startOptions, $intakeGroupId, + $observabilityContext, &$startCommand, &$signalCommand, &$task, @@ -3063,420 +3284,396 @@ private function attemptSignalWithStartInternal( $currentRun = $this->currentRunForInstance($instance, true); if ($currentRun instanceof WorkflowRun && $this->runIsActive($currentRun)) { - $run = $currentRun; - - $this->loadLockedRunRelations($run, $instance); + [$startCommand, $signalCommand, $task] = $this->admitSignalWithExistingStart( + $instance, + $currentRun, + $commandContext, + $name, + $signalArguments, + $startArguments, + $observabilityContext, + ); - $signalAdmission = $this->signalAdmissionForRun($run, $name); + return; + } - if (($signalAdmission['allowed'] ?? false) !== true) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - $run, - $name, - $signalArguments, - 'unknown_signal', - [], - $signalAdmission['message'] ?? null, - $signalAdmission['payload'] ?? [], - ); + [$startCommand, $signalCommand, $task, $startedRun] = $this->admitSignalWithNewRun( + $instance, + $commandContext, + $name, + $signalArguments, + $startArguments, + $startOptions, + $observabilityContext, + ); + }, self::mysqlCommandTransactionAttempts() ?? 1); - return; - } + if ($startedRun instanceof WorkflowRun) { + LifecycleEventDispatcher::workflowStarted($startedRun); + } - $validatedSignalArguments = $this->validatedSignalArgumentsForRun($run, $name, $signalArguments); + $this->refresh(); - if ($validatedSignalArguments['validation_errors'] !== []) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - $run, - $name, - $signalArguments, - 'invalid_signal_arguments', - $validatedSignalArguments['validation_errors'], - ); + if ($task instanceof WorkflowTask) { + TaskDispatcher::dispatch($task); + } - return; - } + if (! $signalCommand instanceof WorkflowCommand) { + throw new LogicException(sprintf( + 'Workflow instance [%s] failed to record a signal-with-start command.', + $this->instance->id, + )); + } - $signalArguments = $validatedSignalArguments['arguments']; + $signalWithStartResult = SignalWithStartResult::fromCommands($signalCommand, $startCommand, $intakeGroupId); - try { - StructuralLimits::guardPendingSignals($run); - } catch (StructuralLimitExceededException $e) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - $run, - $name, - $signalArguments, - 'structural_limit_exceeded', - ); + if ($signalWithStartResult->accepted()) { + self::recordSignalSent($this->instance->id, $name, $signalArguments); + } - return; - } - - $metadata = WorkflowMetadata::fromStartArguments($startArguments); - - /** @var WorkflowCommand $startCommand */ - $startCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( - $commandContext, - [ - 'command_type' => CommandType::Start->value, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::ReturnedExistingActive->value, - 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), - 'payload' => Serializer::serializeWithCodec( - $run->payload_codec ?? CodecRegistry::defaultCodec(), - $metadata->arguments - ), - 'accepted_at' => now(), - 'applied_at' => now(), - ], - )); - - WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ - 'workflow_command_id' => $startCommand->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => AvroValueJsonProjection::project($run->typedMemos()), - 'outcome' => $startCommand->outcome?->value, - ], null, $startCommand); - - /** @var WorkflowCommand $signalCommand */ - $signalCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( - $commandContext, - [ - 'command_type' => CommandType::Signal->value, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::SignalReceived->value, - ...$this->signalCommandPayloadAttributes($name, $signalArguments), - 'accepted_at' => now(), - ], - )); - - $signalWaitId = $this->signalWaitIdForAcceptedCommand($run, $name, $signalCommand->id); - $signal = $this->recordAcceptedSignal( - $instance, - $run, - $signalCommand, - $name, - $signalArguments, - $signalWaitId, - ); - - WorkflowHistoryEvent::record($run, HistoryEventType::SignalReceived, array_filter([ - 'workflow_command_id' => $signalCommand->id, - 'signal_id' => $signal->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'signal_name' => $name, - 'signal_wait_id' => $signalWaitId, - 'arguments' => ExternalPayloads::historyValue( - is_string($signal->arguments) ? $signal->arguments : null, - is_string($signal->payload_codec) ? $signal->payload_codec : null, - is_string($run->namespace) ? $run->namespace : null, - ), - 'payload_codec' => $signal->payload_codec, - ], static fn (mixed $value): bool => $value !== null), null, $signalCommand); - - if (! $this->hasOpenWorkflowTask($run->id)) { - $task = PendingMessageTask::createForRun($run); - } + return $signalWithStartResult; + } - self::projectRun($run, self::PROJECTION_RUN_RELATIONS); + /** + * @return array{WorkflowCommand|null, WorkflowCommand, WorkflowTask|null, WorkflowRun|null} + */ + private function admitSignalWithNewRun( + WorkflowInstance $instance, + CommandContext $commandContext, + string $name, + array $signalArguments, + array $startArguments, + StartOptions $startOptions, + ?array $observabilityContext, + ): array { + $workflowClass = TypeRegistry::resolveWorkflowClass($instance->workflow_class, $instance->workflow_type); + $signalAdmission = $this->validateNewRunSignal( + $instance, + $workflowClass, + $commandContext, + $name, + $signalArguments, + ); + if ($signalAdmission instanceof WorkflowCommand) { + return [null, $signalAdmission, null, null]; + } - return; - } + $signalArguments = $signalAdmission; + $metadata = WorkflowMetadata::fromStartArguments($startArguments); + $commandContract = RunCommandContract::snapshot($workflowClass); + [$businessKey, $visibilityLabels, $memo, $searchAttributes] = $this->initialVisibilityMetadata( + $instance, + $startOptions, + ); + $executionTimeoutSeconds = $startOptions->executionTimeoutSeconds; + $runTimeoutSeconds = $startOptions->runTimeoutSeconds; + $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); + $queue = RoutingResolver::workflowQueue($workflowClass, $metadata); + $signalCommand = $this->rejectIncompatibleSignalWithStart( + $instance, + $commandContext, + $name, + $signalArguments, + $connection, + $queue, + ); + if ($signalCommand instanceof WorkflowCommand) { + return [null, $signalCommand, null, null]; + } - $workflowClass = TypeRegistry::resolveWorkflowClass($instance->workflow_class, $instance->workflow_type); + if ($instance->workflow_class !== $workflowClass) { + $instance->forceFill([ + 'workflow_class' => $workflowClass, + ])->save(); + } - if (! WorkflowDefinition::hasSignal($workflowClass, $name)) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - null, - $name, - $signalArguments, - 'unknown_signal', - ); + $startedAt = now(); + $executionDeadlineAt = $executionTimeoutSeconds !== null + ? $startedAt->copy() + ->addSeconds($executionTimeoutSeconds) + : null; + $runDeadlineAt = $runTimeoutSeconds !== null + ? $startedAt->copy() + ->addSeconds($runTimeoutSeconds) + : null; + $payloadCodec = CodecRegistry::defaultCodec(); + $serializedArguments = ExternalPayloads::externalizeForNamespace( + Serializer::serializeWithCodec($payloadCodec, $metadata->arguments), + $payloadCodec, + is_string($instance->namespace) ? $instance->namespace : null, + ); - return; - } + /** @var WorkflowRun $run */ + $run = self::runQuery()->create([ + 'workflow_instance_id' => $instance->id, + 'run_number' => $instance->run_count + 1, + 'observability_context' => $observabilityContext, + 'workflow_class' => $workflowClass, + 'workflow_type' => $instance->workflow_type, + 'namespace' => $instance->namespace, + 'business_key' => $businessKey, + 'visibility_labels' => $visibilityLabels, + 'run_timeout_seconds' => $runTimeoutSeconds, + 'execution_deadline_at' => $executionDeadlineAt, + 'run_deadline_at' => $runDeadlineAt, + 'status' => RunStatus::Pending->value, + 'compatibility' => WorkerCompatibility::current(), + 'payload_codec' => $payloadCodec, + 'arguments' => $serializedArguments, + 'connection' => $connection, + 'queue' => $queue, + 'started_at' => $startedAt, + 'last_progress_at' => $startedAt, + 'last_history_sequence' => 0, + ]); - $validatedSignalArguments = $this->validatedSignalArgumentsForWorkflow( - $workflowClass, - $name, - $signalArguments - ); + $this->seedTypedVisibilityMetadata($run, $memo, $searchAttributes); - if ($validatedSignalArguments['validation_errors'] !== []) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - null, - $name, - $signalArguments, - 'invalid_signal_arguments', - $validatedSignalArguments['validation_errors'], - ); + $startCommand = $this->recordNewStartCommand( + $instance, + $run, + $payloadCodec, + $serializedArguments, + $commandContext, + ); - return; - } + $instance->forceFill([ + 'current_run_id' => $run->id, + 'business_key' => $businessKey, + 'visibility_labels' => $visibilityLabels, + 'memo' => $memo, + 'execution_timeout_seconds' => $executionTimeoutSeconds, + 'started_at' => $startedAt, + 'run_count' => $run->run_number, + ])->save(); - $signalArguments = $validatedSignalArguments['arguments']; - $metadata = WorkflowMetadata::fromStartArguments($startArguments); - $commandContract = RunCommandContract::snapshot($workflowClass); - $businessKey = $startOptions->businessKey ?? $instance->business_key; - $visibilityLabels = $startOptions->labels !== [] - ? $startOptions->labels - : (is_array($instance->visibility_labels) ? $instance->visibility_labels : null); - $memo = $startOptions->memo !== [] - ? $startOptions->memo - : (is_array($instance->memo) ? $instance->memo : null); - $searchAttributes = $startOptions->searchAttributes !== [] - ? $startOptions->searchAttributes - : null; - $executionTimeoutSeconds = $startOptions->executionTimeoutSeconds; - $runTimeoutSeconds = $startOptions->runTimeoutSeconds; - $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); - $queue = RoutingResolver::workflowQueue($workflowClass, $metadata); - $startBlockedReason = WorkflowStartGate::blockedReason( - WorkerCompatibility::current(), - $connection, - $queue, - ); + $memoPayload = self::visibilityMetadataPayload($memo); + $searchAttributesPayload = self::visibilityMetadataPayload($searchAttributes); + + $this->recordInitialStartAccepted($instance, $run, $startCommand, $memoPayload, $searchAttributesPayload); + $this->recordWorkflowStartedHistory($instance, $run, $startCommand, $workflowClass, $commandContract, [ + 'memo' => $memoPayload, + 'search_attributes' => $searchAttributesPayload, + 'execution_timeout_seconds' => $executionTimeoutSeconds, + 'run_timeout_seconds' => $runTimeoutSeconds, + 'execution_deadline_at' => $executionDeadlineAt, + 'run_deadline_at' => $runDeadlineAt, + ]); - if ($startBlockedReason !== null) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - null, - $name, - $signalArguments, - $startBlockedReason, - [], - WorkflowStartGate::blockedMessage( - sprintf( - 'Workflow instance [%s] cannot start via signal-with-start [%s].', - $instance->id, - $name, - ), - WorkerCompatibility::current(), - $connection, - $queue, - ) ?? sprintf( - 'Workflow instance [%s] cannot start via signal-with-start [%s].', - $instance->id, - $name, - ), - ); + $task = $this->createInitialWorkflowTask($run); - return; - } + /** @var WorkflowCommand $signalCommand */ + $signalCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( + $commandContext, + [ + 'command_type' => CommandType::Signal->value, + 'observability_context' => $observabilityContext, + 'target_scope' => 'instance', + 'status' => CommandStatus::Accepted->value, + 'outcome' => CommandOutcome::SignalReceived->value, + ...$this->signalCommandPayloadAttributes($name, $signalArguments), + 'accepted_at' => now(), + ], + )); - if ($instance->workflow_class !== $workflowClass) { - $instance->forceFill([ - 'workflow_class' => $workflowClass, - ])->save(); - } + $signalWaitId = $this->signalWaitIdForAcceptedCommand($run, $name, $signalCommand->id); + $signal = $this->recordAcceptedSignal( + $instance, + $run, + $signalCommand, + $name, + $signalArguments, + $signalWaitId, + ); - $startedAt = now(); - $executionDeadlineAt = $executionTimeoutSeconds !== null - ? $startedAt->copy() - ->addSeconds($executionTimeoutSeconds) - : null; - $runDeadlineAt = $runTimeoutSeconds !== null - ? $startedAt->copy() - ->addSeconds($runTimeoutSeconds) - : null; - $payloadCodec = CodecRegistry::defaultCodec(); - $serializedArguments = ExternalPayloads::externalizeForNamespace( - Serializer::serializeWithCodec($payloadCodec, $metadata->arguments), - $payloadCodec, - is_string($instance->namespace) ? $instance->namespace : null, - ); + $this->recordSignalReceivedHistory($instance, $run, $signalCommand, $signal, $name, $signalWaitId); - /** @var WorkflowRun $run */ - $run = self::runQuery()->create([ - 'workflow_instance_id' => $instance->id, - 'run_number' => $instance->run_count + 1, - 'workflow_class' => $workflowClass, - 'workflow_type' => $instance->workflow_type, - 'namespace' => $instance->namespace, - 'business_key' => $businessKey, - 'visibility_labels' => $visibilityLabels, - 'run_timeout_seconds' => $runTimeoutSeconds, - 'execution_deadline_at' => $executionDeadlineAt, - 'run_deadline_at' => $runDeadlineAt, - 'status' => RunStatus::Pending->value, - 'compatibility' => WorkerCompatibility::current(), - 'payload_codec' => $payloadCodec, - 'arguments' => $serializedArguments, - 'connection' => $connection, - 'queue' => $queue, - 'started_at' => $startedAt, - 'last_progress_at' => $startedAt, - 'last_history_sequence' => 0, - ]); + self::projectRun($run, self::PROJECTION_RUN_RELATIONS); - $this->seedTypedVisibilityMetadata($run, $memo, $searchAttributes); + return [$startCommand, $signalCommand, $task, $run]; + } - /** @var WorkflowCommand $startCommand */ - $startCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( + /** + * @return array{WorkflowCommand|null, WorkflowCommand, WorkflowTask|null} + */ + private function admitSignalWithExistingStart( + WorkflowInstance $instance, + WorkflowRun $run, + CommandContext $commandContext, + string $name, + array $signalArguments, + array $startArguments, + ?array $observabilityContext, + ): array { + $this->loadLockedRunRelations($run, $instance); + $signalAdmission = $this->signalAdmissionForRun($run, $name); + if (($signalAdmission['allowed'] ?? false) !== true) { + return [null, $this->rejectSignalCommandForContext( $commandContext, - [ - 'command_type' => CommandType::Start->value, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::StartedNew->value, - 'payload_codec' => $payloadCodec, - 'payload' => $serializedArguments, - 'accepted_at' => now(), - 'applied_at' => now(), - ], - )); - - $instance->forceFill([ - 'current_run_id' => $run->id, - 'business_key' => $businessKey, - 'visibility_labels' => $visibilityLabels, - 'memo' => $memo, - 'execution_timeout_seconds' => $executionTimeoutSeconds, - 'started_at' => $startedAt, - 'run_count' => $run->run_number, - ])->save(); - - $memoPayload = self::visibilityMetadataPayload($memo); - $searchAttributesPayload = self::visibilityMetadataPayload($searchAttributes); - - WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ - 'workflow_command_id' => $startCommand->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => $memoPayload, - 'search_attributes' => $searchAttributesPayload, - 'outcome' => $startCommand->outcome?->value, - ], null, $startCommand); - - WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowStarted, [ - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_command_id' => $startCommand->id, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => $memoPayload, - 'search_attributes' => $searchAttributesPayload, - 'execution_timeout_seconds' => $executionTimeoutSeconds, - 'run_timeout_seconds' => $runTimeoutSeconds, - 'execution_deadline_at' => $executionDeadlineAt?->toIso8601String(), - 'run_deadline_at' => $runDeadlineAt?->toIso8601String(), - 'workflow_definition_fingerprint' => WorkflowDefinition::fingerprint($workflowClass), - 'declared_queries' => $commandContract['queries'], - 'declared_query_contracts' => $commandContract['query_contracts'], - 'declared_signals' => $commandContract['signals'], - 'declared_signal_contracts' => $commandContract['signal_contracts'], - 'declared_updates' => $commandContract['updates'], - 'declared_update_contracts' => $commandContract['update_contracts'], - 'declared_entry_method' => $commandContract['entry_method'], - 'declared_entry_mode' => $commandContract['entry_mode'], - 'declared_entry_declaring_class' => $commandContract['entry_declaring_class'], - ], null, $startCommand); - - /** @var WorkflowTask $task */ - $task = self::taskQuery()->create([ - 'workflow_run_id' => $run->id, - 'namespace' => $run->namespace, - 'task_type' => TaskType::Workflow->value, - 'status' => TaskStatus::Ready->value, - 'available_at' => now(), - 'payload' => [], - 'connection' => $run->connection, - 'queue' => $run->queue, - 'compatibility' => $run->compatibility, - ]); + $instance, + $run, + $name, + $signalArguments, + 'unknown_signal', + [], + $signalAdmission['message'] ?? null, + $signalAdmission['payload'] ?? [], + ), null]; + } - /** @var WorkflowCommand $signalCommand */ - $signalCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( + $validatedSignalArguments = $this->validatedSignalArgumentsForRun($run, $name, $signalArguments); + if ($validatedSignalArguments['validation_errors'] !== []) { + return [null, $this->rejectSignalCommandForContext( $commandContext, - [ - 'command_type' => CommandType::Signal->value, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::SignalReceived->value, - ...$this->signalCommandPayloadAttributes($name, $signalArguments), - 'accepted_at' => now(), - ], - )); + $instance, + $run, + $name, + $signalArguments, + 'invalid_signal_arguments', + $validatedSignalArguments['validation_errors'], + ), null]; + } + $signalArguments = $validatedSignalArguments['arguments']; - $signalWaitId = $this->signalWaitIdForAcceptedCommand($run, $name, $signalCommand->id); - $signal = $this->recordAcceptedSignal( + try { + StructuralLimits::guardPendingSignals($run); + } catch (StructuralLimitExceededException $e) { + return [null, $this->rejectSignalCommandForContext( + $commandContext, $instance, $run, - $signalCommand, $name, $signalArguments, - $signalWaitId, - ); + 'structural_limit_exceeded', + ), null]; + } - WorkflowHistoryEvent::record($run, HistoryEventType::SignalReceived, array_filter([ - 'workflow_command_id' => $signalCommand->id, - 'signal_id' => $signal->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'signal_name' => $name, - 'signal_wait_id' => $signalWaitId, - 'arguments' => ExternalPayloads::historyValue( - is_string($signal->arguments) ? $signal->arguments : null, - is_string($signal->payload_codec) ? $signal->payload_codec : null, - is_string($run->namespace) ? $run->namespace : null, + $metadata = WorkflowMetadata::fromStartArguments($startArguments); + $startCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( + $commandContext, + [ + 'command_type' => CommandType::Start->value, + 'target_scope' => 'instance', + 'status' => CommandStatus::Accepted->value, + 'outcome' => CommandOutcome::ReturnedExistingActive->value, + 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), + 'payload' => Serializer::serializeWithCodec( + $run->payload_codec ?? CodecRegistry::defaultCodec(), + $metadata->arguments ), - 'payload_codec' => $signal->payload_codec, - ], static fn (mixed $value): bool => $value !== null), null, $signalCommand); - - self::projectRun($run, self::PROJECTION_RUN_RELATIONS); - - $startedRun = $run; - }, self::mysqlCommandTransactionAttempts() ?? 1); + 'accepted_at' => now(), + 'applied_at' => now(), + ], + )); + WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ + 'workflow_command_id' => $startCommand->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => AvroValueJsonProjection::project($run->typedMemos()), + 'outcome' => $startCommand->outcome?->value, + ], null, $startCommand); + + $signalCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( + $commandContext, + [ + 'command_type' => CommandType::Signal->value, + 'observability_context' => $observabilityContext, + 'target_scope' => 'instance', + 'status' => CommandStatus::Accepted->value, + 'outcome' => CommandOutcome::SignalReceived->value, + ...$this->signalCommandPayloadAttributes($name, $signalArguments), + 'accepted_at' => now(), + ], + )); + $signalWaitId = $this->signalWaitIdForAcceptedCommand($run, $name, $signalCommand->id); + $signal = $this->recordAcceptedSignal( + $instance, + $run, + $signalCommand, + $name, + $signalArguments, + $signalWaitId, + ); + $this->recordSignalReceivedHistory($instance, $run, $signalCommand, $signal, $name, $signalWaitId); - if ($startedRun instanceof WorkflowRun) { - LifecycleEventDispatcher::workflowStarted($startedRun); + $task = null; + if (! $this->hasOpenWorkflowTask($run->id)) { + $task = PendingMessageTask::createForRun($run); } + self::projectRun($run, self::PROJECTION_RUN_RELATIONS); - $this->refresh(); + return [$startCommand, $signalCommand, $task]; + } - if ($task instanceof WorkflowTask) { - TaskDispatcher::dispatch($task); + /** + * @return array|WorkflowCommand + */ + private function validateNewRunSignal( + WorkflowInstance $instance, + string $workflowClass, + CommandContext $commandContext, + string $name, + array $signalArguments, + ): array|WorkflowCommand { + if (! WorkflowDefinition::hasSignal($workflowClass, $name)) { + return $this->rejectSignalCommandForContext( + $commandContext, + $instance, + null, + $name, + $signalArguments, + 'unknown_signal', + ); } - if (! $signalCommand instanceof WorkflowCommand) { - throw new LogicException(sprintf( - 'Workflow instance [%s] failed to record a signal-with-start command.', - $this->instance->id, - )); + $validated = $this->validatedSignalArgumentsForWorkflow($workflowClass, $name, $signalArguments); + if ($validated['validation_errors'] !== []) { + return $this->rejectSignalCommandForContext( + $commandContext, + $instance, + null, + $name, + $signalArguments, + 'invalid_signal_arguments', + $validated['validation_errors'], + ); } - $signalWithStartResult = SignalWithStartResult::fromCommands($signalCommand, $startCommand, $intakeGroupId); + return $validated['arguments']; + } - if ($signalWithStartResult->accepted()) { - self::recordSignalSent($this->instance->id, $name, $signalArguments); + private function rejectIncompatibleSignalWithStart( + WorkflowInstance $instance, + CommandContext $commandContext, + string $name, + array $signalArguments, + ?string $connection, + ?string $queue, + ): ?WorkflowCommand { + $reason = WorkflowStartGate::blockedReason(WorkerCompatibility::current(), $connection, $queue); + if ($reason === null) { + return null; } - return $signalWithStartResult; + return $this->rejectSignalCommandForContext( + $commandContext, + $instance, + null, + $name, + $signalArguments, + $reason, + [], + WorkflowStartGate::blockedMessage( + sprintf('Workflow instance [%s] cannot start via signal-with-start [%s].', $instance->id, $name), + WorkerCompatibility::current(), + $connection, + $queue, + ) ?? sprintf('Workflow instance [%s] cannot start via signal-with-start [%s].', $instance->id, $name), + ); } /** diff --git a/src/config/workflows.php b/src/config/workflows.php index 732d4347f..95364d519 100644 --- a/src/config/workflows.php +++ b/src/config/workflows.php @@ -20,6 +20,11 @@ return [ 'workflows_folder' => 'Workflows', + 'tracing' => [ + 'enabled' => false, + 'baggage_allowlist' => [], + ], + 'stored_workflow_model' => Workflow\Models\StoredWorkflow::class, 'stored_workflow_exception_model' => Workflow\Models\StoredWorkflowException::class, diff --git a/src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php b/src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php new file mode 100644 index 000000000..d1669dd57 --- /dev/null +++ b/src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php @@ -0,0 +1,38 @@ +getConnection()); + foreach (array_unique(['workflow_runs', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && ! $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->json('observability_context') + ->nullable(); + }); + } + } + } + + public function down(): void + { + $model = ConfiguredV2Models::resolve('run_model', WorkflowRun::class); + $schema = Schema::connection($this->getConnection()); + foreach (array_unique(['workflow_runs', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->dropColumn('observability_context'); + }); + } + } + } +}; diff --git a/src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php b/src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php new file mode 100644 index 000000000..b275dbbff --- /dev/null +++ b/src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php @@ -0,0 +1,38 @@ +getConnection()); + foreach (array_unique(['workflow_commands', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && ! $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->json('observability_context') + ->nullable(); + }); + } + } + } + + public function down(): void + { + $model = ConfiguredV2Models::resolve('command_model', WorkflowCommand::class); + $schema = Schema::connection($this->getConnection()); + foreach (array_unique(['workflow_commands', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->dropColumn('observability_context'); + }); + } + } + } +}; diff --git a/src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php b/src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php new file mode 100644 index 000000000..69f5737dd --- /dev/null +++ b/src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php @@ -0,0 +1,38 @@ +getConnection()); + foreach (array_unique(['workflow_tasks', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && ! $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->json('observability_context') + ->nullable(); + }); + } + } + } + + public function down(): void + { + $model = ConfiguredV2Models::resolve('task_model', WorkflowTask::class); + $schema = Schema::connection($this->getConnection()); + foreach (array_unique(['workflow_tasks', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->dropColumn('observability_context'); + }); + } + } + } +}; diff --git a/tests/Feature/V2/V2ConfiguredCoreModelsTest.php b/tests/Feature/V2/V2ConfiguredCoreModelsTest.php index ec371ccc9..e9bc6ceae 100644 --- a/tests/Feature/V2/V2ConfiguredCoreModelsTest.php +++ b/tests/Feature/V2/V2ConfiguredCoreModelsTest.php @@ -7,7 +7,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\Fixtures\V2\TestConfiguredStorageWorkflow; use Tests\SchemaTestCase; use Workflow\V2\Models\WorkflowCommand; @@ -32,6 +34,14 @@ public function testCoreRuntimeUsesConfiguredInstanceRunAndTaskModels(): void config() ->set('workflows.v2.task_model', ConfiguredCoreWorkflowTask::class); + foreach ([ + '2026_10_15_000100_add_observability_context_to_workflow_runs.php', + '2026_10_16_000100_add_observability_context_to_workflow_tasks.php', + ] as $migrationFile) { + $migration = require __DIR__ . '/../../../src/migrations/' . $migrationFile; + $migration->up(); + } + WorkflowStub::fake(); $workflow = WorkflowStub::make(TestConfiguredStorageWorkflow::class, 'configured-runtime-path'); @@ -70,6 +80,111 @@ public function testCoreRuntimeUsesConfiguredInstanceRunAndTaskModels(): void $this->assertSame($workflow->output(), WorkflowStub::loadRun((string) $workflow->runId())->output()); } + public static function carrierMigrations(): iterable + { + yield 'run' => [ + 'run_model', ConfiguredCoreWorkflowRun::class, + '2026_10_15_000100_add_observability_context_to_workflow_runs.php', + ]; + yield 'command' => [ + 'command_model', ConfiguredCoreWorkflowCommand::class, + '2026_10_15_000200_add_observability_context_to_workflow_commands.php', + ]; + yield 'task' => [ + 'task_model', ConfiguredCoreWorkflowTask::class, + '2026_10_16_000100_add_observability_context_to_workflow_tasks.php', + ]; + } + + /** + * @param class-string<\Illuminate\Database\Eloquent\Model> $model + */ + #[DataProvider('carrierMigrations')] + public function testCarrierMigrationsPreserveConfiguredTableRows( + string $configKey, + string $model, + string $migrationFile, + ): void { + $this->assertCarrierMigrationPreservesRows($configKey, $model, $migrationFile); + } + + /** + * @param class-string<\Illuminate\Database\Eloquent\Model> $model + */ + #[DataProvider('carrierMigrations')] + public function testCarrierMigrationsUseTheWorkflowStorageConnection( + string $configKey, + string $model, + string $migrationFile, + ): void { + $previous = config('workflows.storage.connection'); + config() + ->set('database.connections.carrier_storage', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + config() + ->set('workflows.storage.connection', 'carrier_storage'); + try { + $this->assertCarrierMigrationPreservesRows($configKey, $model, $migrationFile); + foreach (['workflow_runs', 'workflow_commands', 'workflow_tasks'] as $defaultTable) { + self::assertTrue(Schema::hasColumn($defaultTable, 'observability_context')); + } + } finally { + config()->set('workflows.storage.connection', $previous); + DB::purge('carrier_storage'); + } + } + + /** + * @param class-string<\Illuminate\Database\Eloquent\Model> $model + */ + private function assertCarrierMigrationPreservesRows(string $configKey, string $model, string $migrationFile): void + { + $connection = config('workflows.storage.connection'); + $connection = is_string($connection) ? $connection : null; + $schema = Schema::connection($connection); + $tableName = (new $model())->getTable(); + $schema->create($tableName, static function (Blueprint $table): void { + $table->string('id') + ->primary(); + $table->text('marker') + ->nullable(); + }); + DB::connection($connection)->table($tableName)->insert([ + [ + 'id' => 'legacy', + 'marker' => 'preserved 雪', + ], + [ + 'id' => 'nullable', + 'marker' => null, + ], + ]); + $rows = static fn (): array => DB::connection($connection)->table($tableName)->orderBy('id')->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + $before = $rows(); + config() + ->set('workflows.v2.' . $configKey, $model); + $migration = require __DIR__ . '/../../../src/migrations/' . $migrationFile; + $migration->up(); + $migration->up(); + self::assertTrue($schema->hasColumn($tableName, 'observability_context')); + $after = $rows(); + foreach ($after as &$row) { + self::assertNull($row['observability_context']); + unset($row['observability_context']); + } + unset($row); + self::assertSame($before, $after); + $migration->down(); + $migration->down(); + self::assertFalse($schema->hasColumn($tableName, 'observability_context')); + self::assertSame($before, $rows()); + } + private function createConfiguredInstancesTable(): void { Schema::create('configured_core_workflow_instances', static function (Blueprint $table): void { @@ -271,3 +386,8 @@ final class ConfiguredCoreWorkflowTask extends WorkflowTask { protected $table = 'configured_core_workflow_tasks'; } + +final class ConfiguredCoreWorkflowCommand extends WorkflowCommand +{ + protected $table = 'configured_core_workflow_commands'; +} diff --git a/tests/Feature/V2/V2TaskDispatchTest.php b/tests/Feature/V2/V2TaskDispatchTest.php index 231a6bb94..048dcd571 100644 --- a/tests/Feature/V2/V2TaskDispatchTest.php +++ b/tests/Feature/V2/V2TaskDispatchTest.php @@ -190,7 +190,12 @@ public function testQueuedTimerAndLocalWritersUseConfiguredWorkflowTaskLease(): $workflowClaim = new ReflectionMethod(RunWorkflowTask::class, 'claimTask'); $workflowClaim->setAccessible(true); - $this->assertSame([true, null, 1], $workflowClaim->invoke(new RunWorkflowTask($workflowTask->id))); + [$claimed, $deferred, $claimedAttempt] = $workflowClaim->invoke(new RunWorkflowTask($workflowTask->id)); + $this->assertNull($deferred); + $this->assertSame(1, $claimedAttempt); + $this->assertInstanceOf(WorkflowTask::class, $claimed); + $this->assertSame($workflowTask->id, $claimed->id); + $this->assertSame(TaskStatus::Leased, $workflowTask->fresh()->status); $this->assertTrue($workflowTask->fresh()->lease_expires_at->equalTo($now->copy()->addSeconds(8))); $timerRun = $this->createWaitingRun('01J00000000000000000TIMER'); @@ -222,7 +227,12 @@ public function testQueuedTimerAndLocalWritersUseConfiguredWorkflowTaskLease(): $timerClaim->setAccessible(true); $timerResult = $timerClaim->invoke(new RunTimerTask($timerTask->id)); - $this->assertSame([$timer->id, null], $timerResult); + [$claimedTimerTask, $deferSeconds] = $timerResult; + $this->assertInstanceOf(WorkflowTask::class, $claimedTimerTask); + $this->assertSame($timerTask->id, $claimedTimerTask->id); + $this->assertSame($timer->id, $claimedTimerTask->payload['timer_id']); + $this->assertNull($deferSeconds); + $this->assertSame(TaskStatus::Leased, $timerTask->fresh()->status); $this->assertTrue($timerTask->fresh()->lease_expires_at->equalTo($now->copy()->addSeconds(8))); $localRun = $this->createWaitingRun('01J00000000000000000LOCAL'); diff --git a/tests/Fixtures/V2/ReplayRegression/workflow-fiber-observability-context.json b/tests/Fixtures/V2/ReplayRegression/workflow-fiber-observability-context.json new file mode 100644 index 000000000..7c783adbf --- /dev/null +++ b/tests/Fixtures/V2/ReplayRegression/workflow-fiber-observability-context.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json", + "fixture_schema": "durable-workflow.replay-regression/v1", + "id": "workflow-fiber-observability-context", + "protocol_version": "1.0", + "bindings": ["php"], + "workflow": { + "type": "Tests\\Fixtures\\V2\\TestTracingReplayWorkflow", + "arguments": [], + "payload_codec": "avro" + }, + "command_sequence": [ + { + "completed": false, + "result": null, + "commands": [ + { + "type": "schedule_activity", + "activity_type": "Tests\\Fixtures\\V2\\TestTracingReplayActivity", + "payload_codec": "avro" + } + ], + "resume_with": [9007199254740993, null, "雪"] + }, + { + "completed": true, + "result": [9007199254740993, null, "雪"], + "commands": [{"type": "complete_workflow"}] + } + ], + "expected": { + "completed": true, + "result": [9007199254740993, null, "雪"], + "commands": [{"type": "complete_workflow"}] + } +} diff --git a/tests/Fixtures/V2/TestTracingReplayActivity.php b/tests/Fixtures/V2/TestTracingReplayActivity.php new file mode 100644 index 000000000..3005c11e8 --- /dev/null +++ b/tests/Fixtures/V2/TestTracingReplayActivity.php @@ -0,0 +1,15 @@ + 0.1, + 'connect_timeout' => 0.1, + ]), + $factory, + $factory, + ))->create($endpoint, ContentTypes::PROTOBUF, maxRetries: 0); + + // The processor captures this context for export. Never trace its own transport. + $exportScope = Configurator::createNoop()->storeInContext(Context::getRoot())->activate(); + try { + return new BatchSpanProcessor( + new SpanExporter($transport), + Clock::getDefault(), + maxQueueSize: 4, + maxExportBatchSize: 2, + autoFlush: false, + ); + } finally { + $exportScope->detach(); + } + } +} diff --git a/tests/Fixtures/V2/TracingFailingChildWorkflow.php b/tests/Fixtures/V2/TracingFailingChildWorkflow.php new file mode 100644 index 000000000..363372fec --- /dev/null +++ b/tests/Fixtures/V2/TracingFailingChildWorkflow.php @@ -0,0 +1,16 @@ +getMessage(); + } + self::timer(1); + + return [ + 'caught' => $caught, + 'value' => [9007199254740993, null, '雪'], + ]; + } +} diff --git a/tests/Fixtures/V2/TracingFanOutWorkflow.php b/tests/Fixtures/V2/TracingFanOutWorkflow.php new file mode 100644 index 000000000..50264fd1a --- /dev/null +++ b/tests/Fixtures/V2/TracingFanOutWorkflow.php @@ -0,0 +1,33 @@ + self::activity(TracingProcessActivity::class, false), + static fn (): mixed => self::activity(TracingProcessActivity::class, new ActivityOptions( + maxAttempts: 2, + backoff: [1], + startToCloseTimeout: 10, + scheduleToCloseTimeout: 30, + ), true), + static fn (): mixed => self::timer(1), + ]); + + return [ + 'ordinary' => $parallel[0], + 'retry' => $parallel[1], + 'local' => $local, + 'timer' => $parallel[2], + ]; + } +} diff --git a/tests/Fixtures/V2/TracingHandledChildFailureWorkflow.php b/tests/Fixtures/V2/TracingHandledChildFailureWorkflow.php new file mode 100644 index 000000000..8ff5fe118 --- /dev/null +++ b/tests/Fixtures/V2/TracingHandledChildFailureWorkflow.php @@ -0,0 +1,24 @@ +getMessage() !== 'deliberate terminal child failure') { + throw $error; + } + } + + return [9007199254740993, null, '雪']; + } +} diff --git a/tests/Fixtures/V2/TracingLargeFanOutActivity.php b/tests/Fixtures/V2/TracingLargeFanOutActivity.php new file mode 100644 index 000000000..3af287550 --- /dev/null +++ b/tests/Fixtures/V2/TracingLargeFanOutActivity.php @@ -0,0 +1,26 @@ + $member, + 'actor_pid' => getmypid(), + ]); + + return [ + 'member' => $member, + 'value' => [9007199254740993, null, '雪'], + ]; + } +} diff --git a/tests/Fixtures/V2/TracingLargeFanOutWorkflow.php b/tests/Fixtures/V2/TracingLargeFanOutWorkflow.php new file mode 100644 index 000000000..e47f97b91 --- /dev/null +++ b/tests/Fixtures/V2/TracingLargeFanOutWorkflow.php @@ -0,0 +1,20 @@ + self::activity(TracingLargeFanOutActivity::class, $member); + } + + return self::all($members); + } +} diff --git a/tests/Fixtures/V2/TracingTerminalFailureActivity.php b/tests/Fixtures/V2/TracingTerminalFailureActivity.php new file mode 100644 index 000000000..9fab50971 --- /dev/null +++ b/tests/Fixtures/V2/TracingTerminalFailureActivity.php @@ -0,0 +1,20 @@ +mergeFromString(file_get_contents('php://input')); +$spans = []; +foreach ($request->getResourceSpans() as $resource) { + foreach ($resource->getScopeSpans() as $scope) { + foreach ($scope->getSpans() as $span) { + $spans[] = [ + 'name' => $span->getName(), + 'trace_id' => bin2hex($span->getTraceId()), + 'span_id' => bin2hex($span->getSpanId()), + ]; + } + } +} +$mode = trim(file_get_contents($directory . '/mode')); +file_put_contents($directory . '/requests.jsonl', json_encode([ + 'mode' => $mode, + 'method' => $_SERVER['REQUEST_METHOD'], + 'path' => $_SERVER['REQUEST_URI'], + 'content_type' => $_SERVER['CONTENT_TYPE'], + 'spans' => $spans, + 'request' => $request->serializeToJsonString(), +], JSON_THROW_ON_ERROR) . PHP_EOL, FILE_APPEND | LOCK_EX); +if ($mode === 'stall') { + usleep(300_000); +} +http_response_code($mode === 'unavailable' ? 503 : 200); +header('Content-Type: application/x-protobuf'); +echo (new Opentelemetry\Proto\Collector\Trace\V1\ExportTraceServiceResponse())->serializeToString(); diff --git a/tests/Fixtures/V2/tracing-http-router.php b/tests/Fixtures/V2/tracing-http-router.php new file mode 100644 index 000000000..d72230261 --- /dev/null +++ b/tests/Fixtures/V2/tracing-http-router.php @@ -0,0 +1,11 @@ + $_SERVER['HTTP_TRACEPARENT'] ?? null, + 'tracestate' => $_SERVER['HTTP_TRACESTATE'] ?? null, + 'baggage' => $_SERVER['HTTP_BAGGAGE'] ?? null, + 'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? null, +], JSON_THROW_ON_ERROR); diff --git a/tests/Fixtures/V2/tracing-legacy-queue-jobs.json b/tests/Fixtures/V2/tracing-legacy-queue-jobs.json new file mode 100644 index 000000000..65bd891f1 --- /dev/null +++ b/tests/Fixtures/V2/tracing-legacy-queue-jobs.json @@ -0,0 +1,25 @@ +{ + "workflow": "2.5.22", + "tag_source": "908e2db9b4337ceae52ebe5fc5e64f3de8e5338b", + "workflow_reference": "1d6acb2db83aa8c0a033e1a5f647d9fda0ce84c7", + "image": "ghcr.io/durable-workflow/server@sha256:f6a6507a5a424dfad03e636ec690b0f1e813d385be9940691c59116446dfd1cc", + "laravel": "v13.34.0", + "php": "8.3.35", + "jobs": [ + { + "class": "Workflow\\V2\\Jobs\\RunWorkflowTask", + "source_sha256": "a139d5807e15d3c62da7289471fd0a4e02f7caab3ffacf5d86b8a147ce2c4168", + "serialized": "O:32:\"Workflow\\V2\\Jobs\\RunWorkflowTask\":2:{s:6:\"taskId\";s:13:\"legacy-task-0\";s:11:\"afterCommit\";b:1;}" + }, + { + "class": "Workflow\\V2\\Jobs\\RunActivityTask", + "source_sha256": "47f88ab431d81090ecd097fc667f06a6848164c4fe5bc20d44ca013d8730f09c", + "serialized": "O:32:\"Workflow\\V2\\Jobs\\RunActivityTask\":2:{s:6:\"taskId\";s:13:\"legacy-task-1\";s:11:\"afterCommit\";b:1;}" + }, + { + "class": "Workflow\\V2\\Jobs\\RunTimerTask", + "source_sha256": "95b033891bd766049c1d5b4a56417ec80176a85b1bce0130c1e1d9d75da656a4", + "serialized": "O:29:\"Workflow\\V2\\Jobs\\RunTimerTask\":2:{s:6:\"taskId\";s:13:\"legacy-task-2\";s:11:\"afterCommit\";b:1;}" + } + ] +} diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php new file mode 100644 index 000000000..4943fefef --- /dev/null +++ b/tests/Fixtures/V2/tracing-process.php @@ -0,0 +1,1640 @@ + $stage, + ], JSON_THROW_ON_ERROR) . PHP_EOL, FILE_APPEND); +} + +recordTracingProbeStage('autoloaded'); + +final class TracingProcessWorkflow extends Workflow +{ + public function __construct(TracingProcessWorkflowDependency $dependency) + { + Log::info('workflow constructor'); + } + + public function handle(bool $retry, TracingProcessWorkflowDependency $dependency, bool $twice = false): array + { + Log::info('workflow before activity'); + $result = self::activity(TracingProcessActivity::class, $retry); + Log::info('workflow after activity'); + + return $twice ? [$result, self::activity(TracingProcessActivity::class, $retry)] : $result; + } +} + +final class TracingProcessWorkflowDependency +{ + public function __construct() + { + Log::info('workflow dependency'); + } +} + +final class TracingRecoveryWorkflow extends Workflow +{ + public function handle(): array + { + return self::activity(TracingProcessActivity::class, new ActivityOptions( + maxAttempts: 2, + backoff: [0], + startToCloseTimeout: 2, + scheduleToCloseTimeout: 10, + ), false); + } +} + +final class TracingTimingWorkflow extends Workflow +{ + public function handle(): array + { + self::timer(1); + + return self::activity(TracingProcessActivity::class, new ActivityOptions( + maxAttempts: 2, + backoff: [1], + startToCloseTimeout: 10, + scheduleToCloseTimeout: 30, + ), true); + } +} + +final class TracingChildParentWorkflow extends Workflow +{ + public function handle(): array + { + return self::child(TracingProcessWorkflow::class, false); + } +} + +final class TracingContinuationWorkflow extends Workflow +{ + public function handle(bool $continued = false): array + { + if (! $continued) { + return self::continueAsNew(true); + } + + return self::activity(TracingProcessActivity::class, false); + } +} + +final class TracingRetryParentWorkflow extends Workflow +{ + public function handle(): array + { + return self::child(TracingRetryChildWorkflow::class); + } +} + +final class TracingRetryChildWorkflow extends Workflow +{ + public function handle(): array + { + if (getenv('DW_TEST_TRACING_FAIL_CHILD') === 'true') { + throw new \RuntimeException('deliberate first child failure'); + } + + return self::activity(TracingProcessActivity::class, false); + } +} + +#[Signal('ready')] +final class TracingCancellationWorkflow extends Workflow +{ + public function handle(): void + { + try { + self::awaitSignal('ready'); + } finally { + Log::info('cancellation cleanup', [ + 'approved_baggage' => ExecutionTracing::current() === [] + ? null : Baggage::fromContext(Context::getCurrent())->getValue('approved'), + ]); + $result = self::activity(TracingProcessActivity::class, false); + self::upsertMemo([ + 'cleanup' => $result, + ]); + } + } +} + +#[Signal('ready')] +final class TracingUpdateLinksWorkflow extends Workflow +{ + private array $values = []; + + public function handle(): array + { + self::awaitSignal('ready'); + + return [ + 'updates' => $this->values, + 'activity' => self::activity(TracingProcessActivity::class, false), + ]; + } + + #[UpdateMethod] + public function accept(array $value): array + { + Log::info('update handler', [ + 'approved_baggage' => ExecutionTracing::current() === [] + ? null : Baggage::fromContext(Context::getCurrent())->getValue('approved'), + ]); + $this->values[] = $value; + + return $value; + } + + #[UpdateMethod] + public function refuse(): never + { + throw new \RuntimeException('deliberate update failure'); + } +} + +#[Signal('ready')] +final class TracingSignalLinksWorkflow extends Workflow +{ + public function handle(): array + { + $first = self::awaitSignal('ready'); + Log::info('signal applied', [ + 'approved_baggage' => ExecutionTracing::current() === [] + ? null : Baggage::fromContext(Context::getCurrent())->getValue('approved'), + ]); + $second = self::awaitSignal('ready'); + Log::info('signal applied', [ + 'approved_baggage' => ExecutionTracing::current() === [] + ? null : Baggage::fromContext(Context::getCurrent())->getValue('approved'), + ]); + + return [ + 'first' => $first, + 'second' => $second, + 'activity' => self::activity(TracingProcessActivity::class, false), + ]; + } +} + +#[Signal('ready')] +final class TracingSignalStartWorkflow extends Workflow +{ + public function handle(): array + { + $signal = self::awaitSignal('ready'); + + return [ + 'signal' => $signal, + 'activity' => self::activity(TracingProcessActivity::class, false), + ]; + } +} + +final class TracingProcessDependency +{ + public function __construct() + { + Log::info('dependency'); + } +} + +final class TracingProcessActivity extends Activity +{ + public static ?array $httpResponse = null; + + public int $tries = 2; + + public function __construct(TracingProcessDependency $dependency) + { + Log::info('constructor'); + } + + public function backoff(): int + { + return 0; + } + + public function handle(bool $retry, TracingProcessDependency $dependency): array + { + Log::info('callback'); + if (getenv('DW_TEST_TRACING_CRASH_FIRST_ATTEMPT') === 'true' && $this->attemptCount() === 1) { + $marker = app() + ->basePath('claimed.json'); + file_put_contents($marker . '.tmp', json_encode( + ExecutionTracing::current() + [ + 'actor_pid' => getmypid(), + 'workflow_run_id' => $this->runId(), + 'activity_id' => $this->activityId(), + 'activity_attempt_id' => $this->attemptId(), + 'activity_attempt_number' => $this->attemptCount(), + 'task_id' => $this->taskId, + 'tracing_context' => ExecutionTracing::current(), + ], + JSON_THROW_ON_ERROR, + )); + rename($marker . '.tmp', $marker); + // The parent test kills this actual worker after observing its committed claim. + while (true) { + usleep(100_000); + } + } + if ($retry && $this->attemptCount() === 1) { + throw new \RuntimeException('deliberate original attempt failure'); + } + + $url = getenv('DW_TEST_TRACING_HTTP_URL'); + if (is_string($url) && $url !== '') { + $response = app(Client::class)->post($url, [ + 'timeout' => 2, + 'connect_timeout' => 1, + 'headers' => [ + 'X-Request-ID' => 'activity-http-request', + 'Authorization' => 'Bearer test-auth-sentinel', + ], + 'json' => [ + 'private' => 'payload-private-sentinel', + ], + 'on_stats' => static function (TransferStats $stats): void { + $context = Span::getCurrent()->getContext(); + Log::info('http transfer', [ + 'request_id' => 'activity-http-request', + 'active_trace_id' => $context->getTraceId(), + 'active_span_id' => $context->getSpanId(), + ]); + }, + ]); + self::$httpResponse = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + } + + return [9007199254740993, null, '雪']; + } +} + +final class TracingProcessApplication extends Application +{ + protected function defineEnvironment($app): void + { + $directory = $app->basePath(); + $app['config']->set([ + 'database.default' => 'tracing', + 'database.migrations' => 'migrations', + 'database.connections.tracing' => [ + 'driver' => 'sqlite', + 'database' => $directory . '/database.sqlite', + 'prefix' => '', + 'foreign_key_constraints' => true, + ], + 'workflows.storage.connection' => 'tracing', + 'workflows.v1.enabled' => false, + 'workflows.v2.compatibility.namespace' => '0', + 'workflows.v2.task_dispatch_mode' => 'queue', + 'workflows.v2.matching_role.queue_wake_enabled' => getenv('DW_TEST_TRACING_LARGE_FAN_OUT') !== 'true' + || getenv('DW_TEST_TRACING_LARGE_FAN_OUT_QUEUE_WAKE') !== 'false', + 'workflows.tracing.enabled' => getenv('DW_TEST_TRACING_ENABLED') !== 'false', + 'workflows.tracing.baggage_allowlist' => ['approved'], + 'queue.default' => 'database', + 'queue.connections.database' => [ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'default', + 'retry_after' => getenv('DW_TEST_TRACING_RECOVERY') === 'true' ? 3 : 90, + ], + 'queue.failed' => [ + 'driver' => 'database-uuids', + 'database' => 'tracing', + 'table' => 'failed_jobs', + ], + 'cache.default' => 'array', + 'cache.stores.array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + 'logging.default' => 'single', + 'logging.channels.single' => [ + 'driver' => 'single', + 'path' => $directory . '/activity.log', + 'tap' => [LogContext::class], + 'formatter' => JsonFormatter::class, + ], + ]); + } +} + +function prepareTracingSchema(): void +{ + recordTracingProbeStage('migrations-started'); + if (Artisan::call('migrate', [ + '--force' => true, + ]) !== 0) { + throw new \RuntimeException(Artisan::output()); + } + recordTracingProbeStage('migrations-ready'); + DB::statement('CREATE TABLE jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, queue TEXT NOT NULL, + payload TEXT NOT NULL, attempts INTEGER NOT NULL, reserved_at INTEGER, + available_at INTEGER NOT NULL, created_at INTEGER NOT NULL)'); + DB::statement('CREATE TABLE failed_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, + connection TEXT NOT NULL, queue TEXT NOT NULL, payload TEXT NOT NULL, exception TEXT NOT NULL, + failed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); + recordTracingProbeStage('schema-ready'); +} + +$directory = $argv[1]; +$mode = $argv[2]; +foreach (['bootstrap/cache', 'storage/logs', 'storage/framework/views', 'config'] as $path) { + if (! is_dir($directory . '/' . $path)) { + mkdir($directory . '/' . $path, 0700, true); + } +} +if (! is_file($directory . '/database.sqlite')) { + touch($directory . '/database.sqlite'); +} +recordTracingProbeStage('application-started'); +$app = TracingProcessApplication::create( + basePath: $directory, + options: [ + 'extra' => [ + 'providers' => [WorkflowServiceProvider::class], + 'dont-discover' => ['*'], + ], + ], +); +recordTracingProbeStage('application-ready'); +if ($mode === 'prepare-http') { + prepareTracingSchema(); + echo json_encode([ + 'workflow_runs' => WorkflowRun::query()->count(), + 'commands' => WorkflowCommand::query()->count(), + 'history_events' => WorkflowHistoryEvent::query()->count(), + 'tasks' => WorkflowTask::query()->count(), + 'jobs' => DB::table('jobs')->count(), + ], JSON_THROW_ON_ERROR), PHP_EOL; + exit(0); +} +$queueErrors = []; +$reportedQueueErrors = []; +$queueDiagnosticsTruncated = false; +if ($largeFanOut) { + $app['events']->listen(\Illuminate\Queue\Events\JobAttempted::class, static function ($event) use ( + &$queueErrors, + &$queueDiagnosticsTruncated + ): void { + if ($event->exception === null) { + return; + } + if (count($queueErrors) >= 64) { + $queueDiagnosticsTruncated = true; + return; + } + $error = $event->exception; + $native = $error; + while ($native->getPrevious() !== null) { + $native = $native->getPrevious(); + } + $connection = DB::connection('tracing'); + $pdo = $connection->getRawPdo(); + $payload = json_decode($event->job->getRawBody(), true); + $queueErrors[] = [ + 'job_id' => (string) $event->job->getJobId(), + 'job_type' => $payload['displayName'] ?? null, + 'delivery_id' => $payload['uuid'] ?? null, + 'deleted' => $event->job->isDeleted(), + 'failed' => $event->job->hasFailed(), + 'error_class' => $error::class, + 'native_code' => $native instanceof \PDOException ? ($native->errorInfo[1] ?? null) : null, + 'operation' => $error instanceof \Illuminate\Database\QueryException + ? strtolower(explode(' ', trim($error->getSql()), 2)[0]) : null, + 'transaction_level' => $connection->transactionLevel(), + 'pdo_in_transaction' => $pdo instanceof \PDO ? $pdo->inTransaction() : null, + ...TracingQueueErrorDiagnostics::context($error), + ]; + }); + $handler = $app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class); + if (! method_exists($handler, 'reportable')) { + throw new \RuntimeException('The tracing fixture needs Laravel exception reporting callbacks.'); + } + $handler->reportable(static function (\Throwable $error) use ( + &$reportedQueueErrors, + &$queueDiagnosticsTruncated + ): void { + if (count($reportedQueueErrors) >= 64) { + $queueDiagnosticsTruncated = true; + return; + } + $native = $error; + while ($native->getPrevious() !== null) { + $native = $native->getPrevious(); + } + $connection = DB::connection('tracing'); + $pdo = $connection->getRawPdo(); + $sql = $error instanceof \Illuminate\Database\QueryException ? $error->getSql() : ''; + $reportedQueueErrors[] = [ + 'error_class' => $error::class, + 'native_code' => $native instanceof \PDOException ? ($native->errorInfo[1] ?? null) : null, + 'operation' => $sql === '' ? null : strtolower(explode(' ', trim($sql), 2)[0]), + 'ack_fence' => $sql === 'delete from "jobs" where "id" = ? and "queue" = ? and "reserved_at" = ? and "attempts" = ?' + ? $error->getBindings() : null, + 'transaction_level' => $connection->transactionLevel(), + 'pdo_in_transaction' => $pdo instanceof \PDO ? $pdo->inTransaction() : null, + ...TracingQueueErrorDiagnostics::context($error), + ]; + }); +} +$exporter = new InMemoryExporter(); +$admissionProcessor = ($admissionProbe || $dispatchProbe || $scheduleProbe) ? new class( + $exporter, + $scheduleProbe ? 'durable.schedule.trigger' : ($dispatchProbe ? 'durable.task.dispatch' : 'durable.workflow.admission') +) extends SimpleSpanProcessor { + public array $boundaries = []; + + public function __construct( + InMemoryExporter $exporter, + private readonly string $observedName + ) { + parent::__construct($exporter); + } + + public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentContext): void + { + if ($span->getName() === $this->observedName) { + $this->boundaries[] = [ + 'event' => 'start', + 'transaction_level' => DB::transactionLevel(), + ]; + } + parent::onStart($span, $parentContext); + } + + public function onEnd(ReadableSpanInterface $span): void + { + if ($span->getName() === $this->observedName) { + $this->boundaries[] = [ + 'event' => 'end', + 'transaction_level' => DB::transactionLevel(), + ]; + } + parent::onEnd($span); + } +} : null; +$processors = [$admissionProcessor ?? new SimpleSpanProcessor($exporter)]; +$exportEndpoint = getenv('DW_TEST_TRACING_EXPORTER_ENDPOINT'); +$exportJournal = getenv('DW_TEST_TRACING_EXPORTER_JOURNAL'); +$exportRequests = static fn (): int => $exportJournal && is_file($exportJournal) + ? count(file($exportJournal, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) : 0; +$initialExportRequests = $exportRequests(); +if ($exportEndpoint) { + $processors[] = TracingExporter::processor($exportEndpoint); +} +$provider = new TracerProvider($processors); +$propagator = new MultiTextMapPropagator([ + TraceContextPropagator::getInstance(), + BaggagePropagator::getInstance(), +]); +$recordedPropagator = null; +if ($signalStart || $signalLinks || $cancellation || $updateLinks || $admissionProbe || $scheduleProbe) { + $recordedPropagator = new class($propagator) implements TextMapPropagatorInterface { + public array $transactionLevels = []; + + public function __construct( + private readonly TextMapPropagatorInterface $delegate + ) { + } + + public function fields(): array + { + return $this->delegate->fields(); + } + + public function inject( + mixed &$carrier, + ?PropagationSetterInterface $setter = null, + ?ContextInterface $context = null, + ): void { + $this->transactionLevels[] = DB::transactionLevel(); + $this->delegate->inject($carrier, $setter, $context); + } + + public function extract( + $carrier, + ?PropagationGetterInterface $getter = null, + ?ContextInterface $context = null, + ): ContextInterface { + return $this->delegate->extract($carrier, $getter, $context); + } + }; + $propagator = $recordedPropagator; +} +$configuration = Configurator::create()->withTracerProvider($provider) + ->withPropagator($propagator) + ->activate(); +$taskOutcomeQueries = 0; +if ($fanOut || $failureOutcomes || $largeFanOut) { + DB::listen(static function ($query) use (&$taskOutcomeQueries): void { + if (str_contains($query->sql, 'workflow_tasks') && str_contains($query->sql, 'observability_context')) { + ++$taskOutcomeQueries; + } + }); +} +$initial = Context::getCurrent(); +$admissionForeignKey = Context::createKey('test.application.admission'); +$dispatchDelivered = []; +$dispatchQueue = static function (): array { + return DB::table('jobs')->orderBy('id')->get(['id', 'payload'])->map(static function ($row): array { + $payload = json_decode($row->payload, true, flags: JSON_THROW_ON_ERROR); + $job = unserialize($payload['data']['command'], [ + 'allowed_classes' => [RunWorkflowTask::class, RunActivityTask::class, RunTimerTask::class], + ]); + if (! $job instanceof RunWorkflowTask && ! $job instanceof RunActivityTask && ! $job instanceof RunTimerTask) { + throw new \RuntimeException('Unexpected queued tracing fixture job.'); + } + + return [ + 'id' => $row->id, + 'class' => $job::class, + 'task_id' => $job->taskId, + 'trace' => $job->dispatchObservabilityContext ?? null, + ]; + })->all(); +}; +$admissionCallbacks = []; +if ($admissionProbe) { + $app['events']->listen(WorkflowStarted::class, static function () use ( + &$admissionCallbacks, + $admissionForeignKey + ): void { + $context = Span::getCurrent()->getContext(); + $admissionCallbacks[] = [ + 'transaction_level' => DB::transactionLevel(), + 'foreign' => Context::getCurrent()->get($admissionForeignKey), + 'fields' => ExecutionTracing::current(), + 'trace_id' => $context->getTraceId(), + 'span_id' => $context->getSpanId(), + ]; + Log::info('admission callback', [ + 'request_id' => 'application-request', + ]); + }); +} +try { + $parent = $mode === 'admit' + ? '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + : '00-11111111111111111111111111111111-2222222222222222-01'; + $headers = [ + 'traceparent' => $parent, + ]; + if ($signalLinks && $mode === 'signal') { + $headers['traceparent'] = getenv('DW_TEST_TRACING_SIGNAL_CALLER'); + $headers['baggage'] = 'approved=caller'; + } elseif ($scheduleProbe === 'buffered' && in_array($mode, ['buffer', 'buffer-full', 'buffer-next'], true)) { + $headers['traceparent'] = $mode === 'buffer' + ? '00-cccccccccccccccccccccccccccccccc-dddddddddddddddd-01' + : '00-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-ffffffffffffffff-01'; + $headers['baggage'] = 'approved=buffer-only'; + } elseif ($updateLinks && $mode === 'update') { + $headers['traceparent'] = getenv('DW_TEST_TRACING_UPDATE_CALLER'); + $headers['baggage'] = 'approved=update-caller'; + } elseif ($updateLinks && $mode === 'signal') { + $headers['traceparent'] = '00-77777777777777777777777777777777-8888888888888888-01'; + } elseif ($cancellation && $mode === 'cancel') { + $headers['traceparent'] = getenv('DW_TEST_TRACING_CANCEL_DUPLICATE') === 'true' + ? '00-cccccccccccccccccccccccccccccccc-dddddddddddddddd-01' + : '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; + $headers['baggage'] = 'approved=caller'; + } elseif (($signalLinks || $cancellation || $updateLinks) && $mode === 'admit') { + $headers['baggage'] = 'approved=origin'; + } + if ($mode === 'admit' && getenv('DW_TEST_TRACING_HTTP_URL')) { + $headers['tracestate'] = 'dwtest=one'; + $headers['baggage'] = 'approved=tenant-a,private=must-not-propagate'; + } + if ($admissionProbe && in_array($mode, ['admit', 'duplicate', 'reject', 'reject-signal-start'], true)) { + $headers['traceparent'] = $mode === 'admit' + ? '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + : '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; + if (getenv('DW_TEST_TRACING_ADMISSION_NO_PARENT') === 'true') { + unset($headers['traceparent']); + } + $headers['baggage'] = 'approved=origin,private=caller-local'; + } + $request = $incomingHttp ? Request::capture() : null; + if ($request !== null) { + $headers = []; + foreach (['traceparent', 'tracestate', 'baggage'] as $header) { + $value = $request->headers->get($header); + if ($value !== null) { + $headers[$header] = $value; + } + } + } + $parentContext = $propagator->extract($headers, context: $initial); + if ($admissionProbe) { + $parentContext = $parentContext->with($admissionForeignKey, 'caller-scope'); + } + // The fixture application owns its HTTP instrumentation, as a real Laravel app would. + $requestSpan = $incomingHttp ? $provider->getTracer('test.application') + ->spanBuilder('POST /start') + ->setSpanKind(SpanKind::KIND_SERVER)->setParent($parentContext)->startSpan() : null; + $scope = ($requestSpan?->storeInContext($parentContext) ?? $parentContext) + ->activate(); + $outsideHttp = null; + $repairReport = null; + $signalStartResult = null; + $cancellationResult = null; + $updateResult = null; + $admissionResult = null; + $scheduleBufferResult = null; + $scheduleDrainResults = null; + $scheduleBackfillResults = null; + $admissionContextPreserved = null; + $childOutcomeControl = null; + try { + if ($mode === 'admit') { + if (! $incomingHttp && getenv('DW_TEST_TRACING_SCHEMA_PREPARED') !== 'true') { + prepareTracingSchema(); + } + if ($request !== null) { + $app['router']->post('/start', static function () use ($retry): JsonResponse { + $stub = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0'); + $stub->start( + $retry, + true, + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + + return new JsonResponse([ + 'run_id' => $stub->runId(), + ], 201); + }); + $kernel = $app->make(Kernel::class); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + if ($response->getStatusCode() !== 201) { + throw new \RuntimeException('HTTP admission failed: ' . $response->getContent()); + } + http_response_code(201); + header('Content-Type: application/json'); + } elseif ($scheduleProbe) { + $creator = $propagator->extract([ + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ], context: $initial)->activate(); + try { + $schedule = ScheduleManager::create( + scheduleId: 'cold-schedule', + workflowClass: TracingProcessWorkflow::class, + cronExpression: $scheduleProbe === 'buffered' ? '0 0 1 1 *' : '* * * * *', + arguments: [false], + overlapPolicy: $scheduleProbe === 'buffered' + ? ScheduleOverlapPolicy::from(getenv('DW_TEST_TRACING_BUFFER_POLICY') ?: 'buffer_one') + : ScheduleOverlapPolicy::Skip, + namespace: '0', + executionTimeoutSeconds: 60, + runTimeoutSeconds: 45 + ); + } finally { + $creator->detach(); + } + if ($scheduleProbe === 'scheduled') { + $schedule->forceFill([ + 'next_fire_at' => now() + ->subSecond(), + ])->save(); + $triggered = ScheduleManager::tick(1); + $instanceId = $triggered[0]['instance_id'] ?? null; + } elseif ($scheduleProbe === 'backfill') { + $from = now() + ->startOfMinute() + ->subMinutes(2); + file_put_contents($directory . '/backfill-from', $from->toIso8601String()); + $scheduleBackfillResults = ScheduleManager::backfill($schedule, $from, $from->copy()->addMinute()); + $instanceId = $scheduleBackfillResults[0]['instance_id'] ?? null; + } else { + $instanceId = ScheduleManager::trigger($schedule); + } + if (! is_string($instanceId) || $instanceId === '') { + throw new \RuntimeException('The actual schedule did not admit a workflow.'); + } + file_put_contents($directory . '/schedule-instance', $instanceId); + if ($scheduleProbe === 'backfill') { + file_put_contents($directory . '/backfill-first-instance', $instanceId); + } + } elseif ($childRetry) { + // A real bridge-authored policy, not an invented PHP child option or history edit. + config() + ->set('workflows.v2.task_dispatch_mode', 'poll'); + WorkflowStub::make(TracingRetryParentWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + $parentRun = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing')->sole(); + $parentTask = WorkflowTask::query()->where('workflow_run_id', $parentRun->id)->sole(); + $bridge = app(DefaultWorkflowTaskBridge::class); + if (! $bridge->claimStatus($parentTask->id)['claimed']) { + throw new \RuntimeException('Parent claim refused.'); + } + config() + ->set('workflows.v2.task_dispatch_mode', 'queue'); + $completion = $bridge->complete($parentTask->id, [[ + 'type' => 'start_child_workflow', + 'workflow_type' => TracingRetryChildWorkflow::class, + 'arguments' => Serializer::serializeWithCodec('avro', []), + 'retry_policy' => [ + 'max_attempts' => 2, + 'backoff_seconds' => [1], + ], + 'execution_timeout_seconds' => 30, + 'run_timeout_seconds' => 20, + ]]); + if (! $completion['completed']) { + throw new \RuntimeException('Parent child admission refused.'); + } + foreach ($completion['created_task_ids'] as $taskId) { + TaskDispatcher::dispatch(WorkflowTask::query()->findOrFail($taskId)); + } + } elseif ($timings) { + WorkflowStub::make(TracingTimingWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + } elseif ($updateLinks) { + WorkflowStub::make(TracingUpdateLinksWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + } elseif ($signalLinks) { + WorkflowStub::make(TracingSignalLinksWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + } elseif ($cancellation) { + WorkflowStub::make(TracingCancellationWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + } elseif ($signalStart) { + $signalStartResult = WorkflowStub::make(TracingSignalStartWorkflow::class, 'cold-tracing', '0') + ->signalWithStart( + 'ready', + [[9007199254740993, null, '雪']], + StartOptions::returnExistingActive()->withExecutionTimeout(60)->withRunTimeout(45) + ); + } elseif ($failureOutcomes) { + WorkflowStub::make(TracingFailureOutcomeWorkflow::class, 'cold-tracing', '0')->start( + $failureOutcomes === 'local', + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + } elseif ($largeFanOut) { + WorkflowStub::make(TracingLargeFanOutWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 120, runTimeoutSeconds: 100), + ); + } elseif ($fanOut) { + WorkflowStub::make(TracingFanOutWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + } elseif (in_array($transition, ['child', 'child-failure', 'continue'], true)) { + $workflow = match ($transition) { + 'child' => TracingChildParentWorkflow::class, + 'child-failure' => TracingHandledChildFailureWorkflow::class, + default => TracingContinuationWorkflow::class, + }; + WorkflowStub::make($workflow, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + } elseif ($recovery) { + WorkflowStub::make(TracingRecoveryWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + } else { + $result = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0')->start( + $retry, + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + if ($admissionProbe) { + $admissionResult = [ + 'command_id' => $result->commandId(), + 'outcome' => $result->outcome(), + ]; + } + } + } elseif ($scheduleProbe === 'backfill' && in_array( + $mode, + ['backfill-duplicate', 'backfill-fail', 'backfill-next'], + true + )) { + $schedule = WorkflowSchedule::query()->where('schedule_id', 'cold-schedule')->sole(); + $from = \Carbon\CarbonImmutable::parse(trim(file_get_contents($directory . '/backfill-from'))); + if ($mode !== 'backfill-duplicate') { + $from = $from->addMinute(); + } + if ($mode === 'backfill-fail') { + $app['events']->listen(WorkflowStarted::class, static function (): never { + throw new \RuntimeException('deliberate backfill admission rollback'); + }); + } + $scheduleBackfillResults = ScheduleManager::backfill($schedule, $from, $from->addMinute()); + if ($mode === 'backfill-next') { + $instanceId = $scheduleBackfillResults[0]['instance_id'] ?? null; + if (! is_string($instanceId) || $instanceId === '') { + throw new \RuntimeException('The next backfill occurrence did not admit a workflow.'); + } + file_put_contents($directory . '/schedule-instance', $instanceId); + } + } elseif ($scheduleProbe === 'buffered' && in_array($mode, ['buffer', 'buffer-full', 'buffer-next'], true)) { + $schedule = WorkflowSchedule::query()->where('schedule_id', 'cold-schedule')->sole(); + $scheduleBufferResult = ScheduleManager::triggerDetailed($schedule)->outcome; + } elseif ($scheduleProbe === 'buffered' && $mode === 'buffer-metadata') { + $schedule = WorkflowSchedule::query()->where('schedule_id', 'cold-schedule')->sole(); + $buffer = $schedule->buffered_actions; + if (getenv('DW_TEST_TRACING_BUFFER_METADATA') === 'legacy') { + unset($buffer[0]['observability_context']); + } else { + $buffer[0]['observability_context'] = 'malformed optional context'; + } + $schedule->buffered_actions = $buffer; + $schedule->save(); + } elseif ($scheduleProbe === 'buffered' && in_array($mode, ['drain', 'drain-fail'], true)) { + if ($mode === 'drain-fail') { + $app['events']->listen(WorkflowStarted::class, static function (): never { + throw new \RuntimeException('deliberate buffered admission rollback'); + }); + } + $triggered = ScheduleManager::tick(1); + $scheduleDrainResults = $triggered; + $instanceId = $triggered[0]['instance_id'] ?? null; + if ($mode === 'drain' && (! is_string($instanceId) || $instanceId === '')) { + throw new \RuntimeException('The actual buffer did not admit its workflow.'); + } + if ($mode === 'drain') { + file_put_contents($directory . '/schedule-instance', $instanceId); + } + } elseif ($mode === 'update' && $updateLinks) { + $step = getenv('DW_TEST_TRACING_UPDATE_STEP'); + $stub = WorkflowStub::make(TracingUpdateLinksWorkflow::class, 'cold-tracing', '0') + ->withCommandContext(CommandContext::phpApi()->with([ + 'request' => [ + 'request_id' => 'tracing-update-' . $step, + ], + ])); + $result = match ($step) { + 'first' => $stub->submitUpdate('accept', [9007199254740993, null, '雪']), + 'failure' => $stub->submitUpdate('refuse'), + 'invalid' => $stub->submitUpdate('notDeclared'), + default => throw new \RuntimeException('Unknown update step.'), + }; + $updateResult = [ + 'accepted' => $result->accepted(), + 'command_id' => $result->commandId(), + 'update_id' => $result->updateId(), + ]; + } elseif ($mode === 'signal' && $updateLinks) { + WorkflowStub::make(TracingUpdateLinksWorkflow::class, 'cold-tracing', '0')->signal('ready'); + } elseif ($mode === 'signal' && $signalLinks) { + WorkflowStub::make(TracingSignalLinksWorkflow::class, 'cold-tracing', '0')->signal( + 'ready', + getenv('DW_TEST_TRACING_SIGNAL_STEP') === 'first' ? [9007199254740993, null, '雪'] : [ + false, + null, + 'é', + ] + ); + } elseif ($mode === 'cancel' && $cancellation) { + $stub = WorkflowStub::make(TracingCancellationWorkflow::class, 'cold-tracing', '0'); + $result = match ($cancellation) { + 'request' => $stub->requestCancellation('deliberate cancellation', 20), + 'cancel' => $stub->cancel('deliberate cancellation'), + 'terminate' => $stub->terminate('deliberate termination'), + default => throw new \RuntimeException('Unknown cancellation mode.'), + }; + $cancellationResult = [ + 'accepted' => $result->accepted(), + 'command_id' => $result->commandId(), + ]; + } elseif ($mode === 'duplicate' && $signalStart) { + $signalStartResult = WorkflowStub::make(TracingSignalStartWorkflow::class, 'cold-tracing', '0') + ->signalWithStart('ready', [['later']], StartOptions::returnExistingActive()); + if (! $signalStartResult->returnedExistingActive()) { + throw new \RuntimeException('Expected the original signal-with-start admission.'); + } + } elseif ($mode === 'reject-signal-start' && $signalStart) { + $signalStartResult = WorkflowStub::make(TracingSignalStartWorkflow::class, 'cold-tracing', '0') + ->attemptSignalWithStart('notDeclared', ['refused-admission-private']); + if (! $signalStartResult->rejected()) { + throw new \RuntimeException('Expected an undeclared signal-with-start refusal.'); + } + } elseif ($mode === 'reject' && $admissionProbe) { + $result = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0')->attemptStart($retry); + if (! $result->rejected()) { + throw new \RuntimeException('Expected an ordinary duplicate refusal.'); + } + $admissionResult = [ + 'command_id' => $result->commandId(), + 'outcome' => $result->outcome(), + ]; + } elseif ($mode === 'duplicate') { + $result = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0') + ->attemptStart($retry, StartOptions::returnExistingActive()); + if (! $result->returnedExistingActive()) { + throw new \RuntimeException('Expected the original active admission.'); + } + if ($admissionProbe) { + $admissionResult = [ + 'command_id' => $result->commandId(), + 'outcome' => $result->outcome(), + ]; + } + } elseif ($mode === 'bridge-fail' && $childRetry) { + $child = WorkflowRun::query()->where('workflow_class', TracingRetryChildWorkflow::class) + ->where('run_number', 1) + ->sole(); + $childTask = WorkflowTask::query()->where('workflow_run_id', $child->id)->sole(); + $bridge = app(DefaultWorkflowTaskBridge::class); + if (! $bridge->claimStatus($childTask->id)['claimed'] + || ! $bridge->complete($childTask->id, [[ + 'type' => 'fail_workflow', + 'message' => 'deliberate first child failure', + 'exception_class' => \RuntimeException::class, + ]])['completed']) { + throw new \RuntimeException('Child failure admission refused.'); + } + } elseif ($mode === 'invalidate-child-outcome' && $transition === 'child') { + $parent = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing')->sole(); + $terminal = WorkflowHistoryEvent::query()->where('event_type', 'WorkflowCompleted') + ->where('workflow_run_id', '!=', $parent->id) + ->sole(); + $task = WorkflowTask::query()->findOrFail($terminal->workflow_task_id); + $carrier = json_decode($task->getRawOriginal('observability_context'), true, flags: JSON_THROW_ON_ERROR); + $field = match (getenv('DW_TEST_TRACING_CHILD_OUTCOME_CONTROL')) { + 'wrong namespace' => 'namespace', + 'wrong task' => 'task_id', + default => throw new \RuntimeException('Unknown optional-carrier control.'), + }; + $carrier[$field] = 'unrelated'; + DB::table('workflow_tasks')->where('id', $task->id)->update([ + 'observability_context' => json_encode($carrier, JSON_THROW_ON_ERROR), + ]); + $childOutcomeControl = [ + 'field' => $field, + 'task_id' => $task->id, + ]; + } elseif ($mode === 'work' || ($mode === 'work-drain' && $largeFanOut)) { + if ($dispatchProbe) { + $dispatchDelivered = $dispatchQueue(); + } + if ($mode === 'work-drain') { + $app['events']->listen(\Illuminate\Queue\Events\Looping::class, static function () use ( + $app, + $directory + ): void { + if (is_file($directory . '/stop-large-workers')) { + $worker = $app->make('queue.worker'); + $worker->shouldQuit = true; + } + }); + file_put_contents($directory . '/ready-worker-' . getmypid(), 'ready'); + $readyDeadline = hrtime(true) + 10_000_000_000; + while (count(glob($directory . '/ready-worker-*') ?: []) < 4 && hrtime(true) < $readyDeadline) { + usleep(10_000); + } + if (count(glob($directory . '/ready-worker-*') ?: []) !== 4) { + throw new \RuntimeException('Original four-worker ready barrier elapsed.'); + } + } + $workerOptions = [ + 'connection' => 'database', + '--queue' => 'default', + '--once' => $mode !== 'work-drain', + '--tries' => 1, + '--timeout' => $recovery ? 1 : 30, + ]; + if ($mode === 'work-drain') { + $workerOptions += [ + '--max-jobs' => 128, + '--max-time' => getenv('DW_TEST_TRACING_CONTINUOUS_WORKERS') === 'true' ? 0 : 45, + '--stop-when-empty' => getenv('DW_TEST_TRACING_CONTINUOUS_WORKERS') !== 'true', + ]; + } + if (Artisan::call('queue:work', $workerOptions) !== 0) { + throw new \RuntimeException(Artisan::output()); + } + Log::info('worker outside'); + if (TracingProcessActivity::$httpResponse !== null) { + $response = app(Client::class)->post(getenv('DW_TEST_TRACING_HTTP_URL'), [ + 'timeout' => 2, + 'connect_timeout' => 1, + 'headers' => [ + 'X-Request-ID' => 'outside-http-request', + ], + ]); + $outsideHttp = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + } + } elseif ($mode === 'repair' && $recovery) { + $runId = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing')->sole()->id; + if (Artisan::call('workflow:v2:repair-pass', [ + '--run-id' => [$runId], + '--json' => true, + ]) !== 0) { + throw new \RuntimeException(Artisan::output()); + } + $repairReport = json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR); + } elseif (! in_array($mode, ['inspect', 'inspect-first'], true)) { + throw new \RuntimeException('Unknown fixture command.'); + } + if ($admissionProbe) { + $admissionContextPreserved = Context::getCurrent() === $parentContext; + } + } finally { + $scope->detach(); + $requestSpan?->end(); + } + $selectedInstance = $scheduleProbe + ? trim( + file_get_contents( + $directory . ($mode === 'inspect-first' ? '/backfill-first-instance' : '/schedule-instance') + ) + ) + : 'cold-tracing'; + $runs = WorkflowRun::query()->where('workflow_instance_id', $selectedInstance); + $run = $transition === 'continue' ? $runs->orderByDesc('run_number') + ->firstOrFail() : $runs->sole(); + $output = [ + 'pid' => getmypid(), + 'framework' => $app->version(), + 'run_id' => $run->id, + 'status' => $run->status->value, + 'output' => $run->workflowOutput(), + 'carrier' => $run->getRawOriginal('observability_context'), + 'execution_deadline' => $run->getRawOriginal('execution_deadline_at'), + 'run_deadline' => $run->getRawOriginal('run_deadline_at'), + 'jobs' => DB::table('jobs')->count(), + 'failed_jobs' => DB::table('failed_jobs')->count(), + 'tasks' => WorkflowTask::query()->count(), + 'repairs' => (int) WorkflowTask::query()->sum('repair_count'), + 'outside_context' => ExecutionTracing::current(), + 'scope_restored' => Context::getCurrent() === $initial, + 'http_response' => TracingProcessActivity::$httpResponse, + 'outside_http' => $outsideHttp, + 'spans' => array_map(static fn ($span): array => [ + 'name' => $span->getName(), + 'status_code' => $span->getStatus() + ->getCode(), + 'kind' => $span->getKind(), + 'trace_id' => $span->getContext() + ->getTraceId(), + 'span_id' => $span->getContext() + ->getSpanId(), + 'parent_id' => $span->getParentContext() + ->getSpanId(), + 'fields' => $span->getAttributes() + ->toArray(), + 'links' => array_map(static fn ($link): array => [ + 'trace_id' => $link->getSpanContext() + ->getTraceId(), + 'span_id' => $link->getSpanContext() + ->getSpanId(), + 'fields' => $link->getAttributes() + ->toArray(), + ], $span->getLinks()), + ], $exporter->getSpans()), + ]; + // Preserve the complete export for dispatch controls. Execution checks separately + // inspect their admission/consumer spans and application command links. + $output['execution_spans'] = array_values(array_filter( + $output['spans'], + static fn (array $span): bool => $span['name'] !== 'durable.task.dispatch' + )); + foreach ($output['execution_spans'] as &$executionSpan) { + $executionSpan['links'] = array_values(array_filter( + $executionSpan['links'], + static fn (array $link): bool => ! in_array( + $link['fields']['durable.link.type'] ?? null, + ['queue_dispatch', 'task_outcome'], + true, + ) + )); + } + unset($executionSpan); + if ($scheduleProbe) { + $schedule = WorkflowSchedule::query()->where('schedule_id', 'cold-schedule')->sole(); + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['schedule'] = [ + 'id' => $schedule->schedule_id, + 'namespace' => $schedule->namespace, + 'fires_count' => (int) $schedule->fires_count, + 'failures_count' => (int) $schedule->failures_count, + 'run_count' => WorkflowRun::query()->count(), + 'latest_instance_id' => $schedule->latest_workflow_instance_id, + 'buffer_result' => $scheduleBufferResult, + 'drain_results' => $scheduleDrainResults, + 'backfill_results' => $scheduleBackfillResults, + 'buffered_actions' => $schedule->buffered_actions, + 'public_buffered_actions' => $schedule->toDetail()['info']['buffered_actions'], + 'instance_id' => $run->workflow_instance_id, + 'boundaries' => $admissionProcessor->boundaries, + 'capture_transaction_levels' => $recordedPropagator->transactionLevels, + 'history_hashes' => $history->map(static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR) + ))->all(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'tasks' => WorkflowTask::query()->orderBy('created_at')->get([ + 'id', 'status', 'attempt_count', 'repair_count', + ])->toArray(), + ]; + } + if ($exportEndpoint) { + $output['exports_before_shutdown'] = $exportRequests() - $initialExportRequests; + } + if ($signalStart) { + $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); + $output['signal_start_outcome'] = $signalStartResult?->startOutcome(); + $output['signal_start_result'] = $signalStartResult === null ? null : [ + 'command_id' => $signalStartResult->commandId(), + 'status' => $signalStartResult->status(), + 'outcome' => $signalStartResult->outcome(), + 'rejection_reason' => $signalStartResult->rejectionReason(), + 'start_command_id' => $signalStartResult->startCommandId(), + 'start_status' => $signalStartResult->startStatus(), + 'start_outcome' => $signalStartResult->startOutcome(), + ]; + $output['capture_transaction_levels'] = $recordedPropagator?->transactionLevels ?? []; + $output['run_count'] = WorkflowRun::query()->count(); + $output['signal_count'] = $history->where('event_type.value', 'SignalReceived')->count(); + $output['completion_count'] = $history->where('event_type.value', 'ActivityCompleted')->count(); + $output['history_hashes'] = $history->map(static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR), + ))->all(); + $output['history_payloads'] = $history->pluck('payload')->all(); + $output['signal_commands'] = WorkflowCommand::query()->where('command_type', 'signal')->orderBy( + 'command_sequence' + ) + ->get() + ->map(static fn ($command): array => [ + 'id' => $command->id, + 'carrier' => $command->getRawOriginal('observability_context'), + ])->all(); + } + if (in_array($transition, ['child', 'child-failure', 'continue'], true)) { + $output['runs'] = WorkflowRun::query()->orderBy('id')->get()->map(static fn ($run): array => [ + 'id' => $run->id, + 'instance_id' => $run->workflow_instance_id, + 'status' => $run->status->value, + 'closed_reason' => $run->closed_reason, + 'carrier' => $run->getRawOriginal('observability_context'), + 'execution_deadline' => $run->getRawOriginal('execution_deadline_at'), + 'run_deadline' => $run->getRawOriginal('run_deadline_at'), + 'history_hashes' => $run->historyEvents() + ->orderBy('sequence') + ->get() + ->map( + static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR) + ), + )->all(), + ])->all(); + $output['activity_count'] = ActivityExecution::query()->count(); + $output['completion_count'] = WorkflowHistoryEvent::query()->where('event_type', 'ActivityCompleted')->count(); + $output['history_payloads'] = WorkflowHistoryEvent::query()->orderBy('id')->pluck('payload')->all(); + $output['child_outcome_control'] = $childOutcomeControl; + $output['transition_events'] = WorkflowHistoryEvent::query() + ->whereIn('event_type', ['WorkflowCompleted', 'WorkflowFailed', 'ChildRunCompleted', 'ChildRunFailed']) + ->orderBy('id') + ->get() + ->map(static fn ($event): array => [ + 'id' => $event->id, + 'run_id' => $event->workflow_run_id, + 'task_id' => $event->workflow_task_id, + 'type' => $event->event_type->value, + 'sequence' => $event->sequence, + 'payload' => $event->payload, + ])->all(); + } + if ($childRetry) { + $output['runs'] = WorkflowRun::query()->orderBy('id')->get()->map(static fn ($row): array => [ + 'id' => $row->id, + 'instance_id' => $row->workflow_instance_id, + 'number' => $row->run_number, + 'status' => $row->status->value, + 'carrier' => $row->getRawOriginal('observability_context'), + 'execution_deadline' => $row->getRawOriginal('execution_deadline_at'), + 'run_deadline' => $row->getRawOriginal('run_deadline_at'), + 'started_at' => $row->getRawOriginal('started_at'), + 'history_hashes' => $row->historyEvents() + ->orderBy('sequence') + ->get() + ->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + ])->all(); + $output['activity_count'] = ActivityExecution::query()->count(); + $output['completion_count'] = WorkflowHistoryEvent::query()->where('event_type', 'ActivityCompleted')->count(); + $output['policy'] = WorkflowChildCall::query()->sole()->retry_policy; + $output['links'] = WorkflowLink::query()->orderBy('id')->get([ + 'id', 'parent_workflow_run_id', 'child_workflow_instance_id', 'child_workflow_run_id', + ])->toArray(); + $output['task_rows'] = WorkflowTask::query()->orderBy('id')->get()->map(static fn ($row): array => [ + 'id' => $row->id, + 'run_id' => $row->workflow_run_id, + 'status' => $row->status->value, + 'attempt_count' => $row->attempt_count, + 'available_at' => $row->getRawOriginal('available_at'), + 'leased_at' => $row->getRawOriginal('leased_at'), + ])->all(); + $output['history_payloads'] = WorkflowHistoryEvent::query()->orderBy('id')->pluck('payload')->all(); + } + if ($cancellation) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['cancellation'] = [ + 'result' => $cancellationResult, + 'deadline' => $run->getRawOriginal('cancellation_deadline_at'), + 'memo' => WorkflowStub::make(TracingCancellationWorkflow::class, 'cold-tracing', '0')->memo(), + 'commands' => WorkflowCommand::query()->orderBy('id')->get(['id', 'command_type'])->toArray(), + 'capture_transaction_levels' => $recordedPropagator->transactionLevels, + 'request_carrier' => $run->cancellation_request_command_id === null ? null + : WorkflowCommand::query()->findOrFail($run->cancellation_request_command_id) + ->getRawOriginal('observability_context'), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'history_hashes' => $history->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'activities' => ActivityExecution::query()->get(['id', 'status', 'attempt_count'])->toArray(), + 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), + ]; + } + if ($updateLinks) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['updates'] = [ + 'submitted' => $updateResult, + 'signal_command_id' => WorkflowCommand::query()->where('command_type', 'signal')->value('id'), + 'capture_transaction_levels' => $recordedPropagator->transactionLevels, + 'commands' => WorkflowCommand::query()->where('command_type', 'update')->orderBy('command_sequence')->get() + ->map(static fn ($command): array => [ + 'id' => $command->id, + 'status' => $command->status->value, + 'outcome' => $command->outcome?->value, + 'carrier' => $command->getRawOriginal('observability_context'), + 'immutable' => array_intersect_key($command->getRawOriginal(), array_flip([ + 'id', 'workflow_run_id', 'command_sequence', 'payload_codec', 'payload', 'context', + 'request_id', 'observability_context', + ])), + ])->all(), + 'rows' => WorkflowUpdate::query()->orderBy('command_sequence')->get()->map(static fn ($update): array => [ + 'id' => $update->id, + 'command_id' => $update->workflow_command_id, + 'status' => $update->status->value, + 'result' => $update->updateResult(), + 'failure_message' => $update->failure_message, + ])->all(), + 'history_hashes' => $history->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), + ]; + } + if ($timings) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['timings'] = [ + 'history_hashes' => $history->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'tasks' => WorkflowTask::query()->orderBy('created_at')->get()->map(static fn ($task): array => [ + 'id' => $task->id, + 'type' => $task->task_type->value, + 'status' => $task->status->value, + 'attempt_count' => $task->attempt_count, + 'available_at' => $task->getRawOriginal('available_at'), + 'leased_at' => $task->getRawOriginal('leased_at'), + 'payload' => $task->payload, + ])->all(), + 'timer' => WorkflowTimer::query()->first()?->getRawOriginal(), + 'attempts' => ActivityAttempt::query()->orderBy('attempt_number')->get([ + 'id', 'attempt_number', 'status', + ])->toArray(), + 'policy' => ActivityExecution::query()->first()?->retry_policy, + 'completion_count' => $history->where('event_type.value', 'ActivityCompleted') + ->count(), + ]; + } + if ($dispatchProbe) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['dispatch'] = [ + 'queued' => $dispatchQueue(), + 'delivered' => $dispatchDelivered, + 'boundaries' => $admissionProcessor->boundaries, + 'history_hashes' => $history->map(static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR) + ))->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'activity_completions' => $history->where('event_type.value', 'ActivityCompleted') + ->count(), + ]; + } + if ($admissionProbe) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['admission'] = [ + 'result' => $admissionResult, + 'context_preserved' => $admissionContextPreserved, + 'boundaries' => $admissionProcessor->boundaries, + 'callbacks' => $admissionCallbacks, + 'capture_transaction_levels' => $recordedPropagator->transactionLevels, + 'history_hashes' => $history->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'run_count' => WorkflowRun::query()->count(), + ]; + } + if ($signalLinks) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['signals'] = [ + 'capture_transaction_levels' => $recordedPropagator->transactionLevels, + 'commands' => WorkflowCommand::query()->where('command_type', 'signal')->orderBy('command_sequence') + ->get() + ->map(static fn ($command): array => [ + 'id' => $command->id, + 'carrier' => $command->getRawOriginal('observability_context'), + 'immutable' => array_intersect_key($command->getRawOriginal(), array_flip([ + 'id', 'workflow_run_id', 'command_sequence', 'message_sequence', 'payload_codec', 'payload', 'context', + 'observability_context', + ])), + ])->all(), + 'history_hashes' => $history->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), + ]; + } + if ($fanOut || $failureOutcomes || $largeFanOut) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $remainingTaskIds = $largeFanOut ? array_column($dispatchQueue(), 'task_id', 'id') : []; + $output[$largeFanOut ? 'large_fan_out' : ($fanOut ? 'fan_out' : 'failure_outcomes')] = [ + 'matching_role' => \Workflow\V2\Support\MatchingRoleSnapshot::current(), + 'task_outcome_queries' => $taskOutcomeQueries, + 'ready_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', time())->count(), + 'reserved_jobs' => DB::table('jobs')->whereNotNull('reserved_at')->count(), + 'delayed_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '>', time())->count(), + 'queue_attempts' => (int) DB::table('jobs')->sum('attempts'), + 'queue_errors' => $queueErrors, + 'reported_queue_errors' => $reportedQueueErrors, + 'queue_diagnostics_truncated' => $queueDiagnosticsTruncated, + 'retry_after_seconds' => config('queue.connections.database.retry_after'), + 'remaining_deliveries' => DB::table('jobs')->orderBy('id')->limit(64)->get([ + 'id', 'queue', 'attempts', 'reserved_at', 'available_at', 'payload', + ])->map(static function ($row) use ($remainingTaskIds): array { + $payload = json_decode($row->payload, true); + return [ + 'id' => (string) $row->id, + 'queue' => $row->queue, + 'attempts' => (int) $row->attempts, + 'reserved_at' => $row->reserved_at, + 'available_at' => $row->available_at, + 'job_type' => $payload['displayName'] ?? null, + 'delivery_id' => $payload['uuid'] ?? null, + 'task_id' => $remainingTaskIds[$row->id] ?? null, + ]; + })->all(), + 'delivery_errors' => DB::table('failed_jobs')->orderBy('id')->limit(64)->get(['exception', 'payload']) + ->map(static function ($row): array { + $lines = explode("\n", $row->exception); + $payload = json_decode($row->payload, true); + $job = unserialize($payload['data']['command'], [ + 'allowed_classes' => [RunWorkflowTask::class, RunActivityTask::class, RunTimerTask::class], + ]); + $frames = array_filter($lines, static fn (string $line): bool => + str_contains($line, '/src/Queue/') || str_contains($line, '/src/V2/') + || str_contains($line, '/Queue/DatabaseQueue.php') + || str_contains($line, '/Queue/Jobs/') + || str_contains($line, 'Next Illuminate\\Database\\QueryException:')); + return [ + 'summary' => substr($lines[0], 0, 256), + 'job_type' => $payload['displayName'] ?? null, + 'delivery_id' => $payload['uuid'] ?? null, + 'task_id' => $job instanceof RunWorkflowTask || $job instanceof RunActivityTask + ? $job->taskId : null, + 'stage_frames' => array_map( + static fn (string $line): string => substr($line, 0, 320), + array_slice(array_values($frames), 0, 8) + ), + ]; + })->all(), + 'history_hashes' => $history->map(static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR), + ))->all(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'activities' => ActivityExecution::query()->orderBy('id')->get([ + 'id', 'status', 'attempt_count', 'current_attempt_id', 'retry_policy', + ])->toArray(), + 'attempts' => ActivityAttempt::query()->orderBy('id')->get([ + 'id', 'activity_execution_id', 'workflow_task_id', 'attempt_number', 'status', + ])->toArray(), + 'tasks' => WorkflowTask::query()->orderBy('id')->get([ + 'id', 'workflow_run_id', 'namespace', 'queue', 'status', 'task_type', 'attempt_count', 'repair_count', + ])->toArray(), + 'outcome_contexts' => WorkflowTask::query()->orderBy('id')->get()->map(static fn ($row): array => [ + 'task_id' => $row->id, + 'run_id' => $row->workflow_run_id, + 'namespace' => $row->namespace, + 'context' => $row->getRawOriginal('observability_context'), + ])->filter(static fn (array $row): bool => $row['context'] !== null)->values()->all(), + 'outcome_events' => $history->filter(static fn ($event): bool => in_array( + $event->event_type->value, + ['ActivityCompleted', 'ActivityFailed', 'TimerFired', 'WorkflowCompleted'], + true, + ))->map(static fn ($event): array => [ + 'id' => $event->id, + 'sequence' => $event->sequence, + 'type' => $event->event_type->value, + 'task_id' => $event->workflow_task_id, + 'payload' => $event->payload, + ])->values() + ->all(), + 'public_tasks' => WorkflowTask::query()->get()->toArray(), + ]; + } + if ($recovery) { + $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); + $output['recovery'] = [ + 'repair_report' => $repairReport, + 'failed_deliveries' => DB::table('failed_jobs')->get(['exception', 'payload'])->map( + static fn ($job): array => [ + 'exception' => substr($job->exception, 0, 700), + 'payload' => $job->payload, + ] + )->all(), + 'activities' => ActivityExecution::query()->orderBy('id')->get()->map(static fn ($activity): array => [ + 'id' => $activity->id, + 'status' => $activity->status->value, + 'attempt_count' => $activity->attempt_count, + 'retry_policy' => $activity->retry_policy, + 'schedule_deadline_at' => $activity->getRawOriginal('schedule_deadline_at'), + 'schedule_to_close_deadline_at' => $activity->getRawOriginal('schedule_to_close_deadline_at'), + 'close_deadline_at' => $activity->getRawOriginal('close_deadline_at'), + ])->all(), + 'attempts' => ActivityAttempt::query()->orderBy('attempt_number')->get()->map( + static function ($attempt): array { + return [ + 'id' => $attempt->id, + 'activity_id' => $attempt->activity_execution_id, + 'task_id' => $attempt->workflow_task_id, + 'number' => $attempt->attempt_number, + 'status' => $attempt->status->value, + 'lease_expires_at' => $attempt->getRawOriginal('lease_expires_at'), + 'started_at' => $attempt->getRawOriginal('started_at'), + ]; + } + )->all(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'history_hashes' => $history->map(static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR), + ))->all(), + 'tasks' => WorkflowTask::query()->orderBy('created_at')->get()->map(static fn ($task): array => [ + 'id' => $task->id, + 'type' => $task->task_type->value, + 'status' => $task->status->value, + 'attempt_count' => $task->attempt_count, + 'repair_count' => $task->repair_count, + 'payload' => $task->payload, + ])->all(), + ]; + } +} finally { + $configuration->detach(); + $shutdownStarted = hrtime(true); + $provider->shutdown(); +} +if ($exportEndpoint) { + $output['export_shutdown_seconds'] = (hrtime(true) - $shutdownStarted) / 1e9; +} +echo json_encode($output, JSON_THROW_ON_ERROR), PHP_EOL; diff --git a/tests/Support/TracingQueueErrorDiagnostics.php b/tests/Support/TracingQueueErrorDiagnostics.php new file mode 100644 index 000000000..8260b2228 --- /dev/null +++ b/tests/Support/TracingQueueErrorDiagnostics.php @@ -0,0 +1,55 @@ +getSql() : null; + + return [ + 'statement' => $statement === null ? null : mb_strcut($statement, 0, 1024, 'UTF-8'), + 'statement_truncated' => $statement !== null && strlen($statement) > 1024, + 'stage_frames' => array_map(static fn (array $frame): array => [ + 'file' => isset($frame['file']) ? basename($frame['file']) : null, + 'line' => $frame['line'] ?? null, + 'function' => isset($frame['function']) ? substr($frame['function'], 0, 128) : null, + ], array_slice($error->getTrace(), 0, 8)), + ]; + } + + public static function isFailedJobLogContention(array $error): bool + { + $code = $error['native_code'] ?? null; + if ( + ($error['error_class'] ?? null) !== QueryException::class + || ! is_int($code) || ! in_array($code & 255, [5, 6], true) + || ($error['operation'] ?? null) !== 'insert' + || ($error['transaction_level'] ?? null) !== 0 + || ($error['pdo_in_transaction'] ?? null) !== false + || ($error['statement_truncated'] ?? null) !== false + || ($error['statement'] ?? null) !== 'insert into "failed_jobs" ' + . '("uuid", "connection", "queue", "payload", "exception", "failed_at") values (?, ?, ?, ?, ?, ?)' + ) { + return false; + } + + $frames = $error['stage_frames'] ?? []; + if (! is_array($frames)) { + return false; + } + + return array_filter($frames, static fn (mixed $frame): bool => is_array($frame) + && ($frame['file'] ?? null) === 'DatabaseUuidFailedJobProvider.php' + && ($frame['function'] ?? null) === 'insert') !== [] + && array_filter($frames, static fn (mixed $frame): bool => is_array($frame) + && ($frame['file'] ?? null) === 'WorkCommand.php' + && ($frame['function'] ?? null) === 'logFailedJob') !== []; + } +} diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php new file mode 100644 index 000000000..8f20e4ef0 --- /dev/null +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -0,0 +1,1738 @@ +artisan('migrate', [ + '--force' => true, + ])->run(); + DB::statement('CREATE TABLE IF NOT EXISTS jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, + queue TEXT NOT NULL, payload TEXT NOT NULL, attempts INTEGER NOT NULL, + reserved_at INTEGER, available_at INTEGER NOT NULL, created_at INTEGER NOT NULL)'); + DB::statement('CREATE TABLE IF NOT EXISTS failed_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, connection TEXT NOT NULL, queue TEXT NOT NULL, + payload TEXT NOT NULL, exception TEXT NOT NULL, failed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); + $this->exporter = new InMemoryExporter(); + $this->provider = new TracerProvider(new SimpleSpanProcessor($this->exporter)); + $this->configuration = Configurator::create()->withTracerProvider($this->provider) + ->withPropagator(TraceContextPropagator::getInstance())->activate(); + TracedActivity::$observations = []; + LocalTracedWorkflow::$scopes = []; + } + + protected function tearDown(): void + { + self::assertSame([], ExecutionTracing::current()); + $this->configuration->detach(); + $this->provider->shutdown(); + parent::tearDown(); + } + + public static function modes(): array + { + return [ + 'default disabled' => [false, false], + 'enabled legacy run' => [true, false], + 'enabled retry' => [true, true], + ]; + } + + #[DataProvider('dispatchFailures')] + public function testPreparedScheduleFilteringUsesBoundedSpanAttributesWithoutChangingBusinessWork( + bool $enabled, + ): void { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $propagator = Configurator::create()->withPropagator(new MultiTextMapPropagator([ + TraceContextPropagator::getInstance(), BaggagePropagator::getInstance(), + ]))->activate(); + $caller = $this->provider->getTracer('application') + ->spanBuilder('request') + ->startSpan(); + $scope = Baggage::getBuilder()->set('private', 'secret')->build() + ->storeInContext($caller->storeInContext(Context::getCurrent()))->activate(); + $schedule = new WorkflowSchedule(); + $schedule->setRawAttributes([ + 'schedule_id' => 'bounded-diagnostic', + 'namespace' => '0', + ]); + try { + self::assertSame(7, ExecutionTracing::scheduleAdmission($schedule, static function () use ($enabled): int { + $carrier = TraceCarrier::admissionParent(); + if ($enabled) { + self::assertNotNull($carrier); + self::assertArrayNotHasKey('baggage', $carrier['carrier']); + } else { + self::assertNull($carrier); + ExecutionTracing::recordCarrierDiagnostic('discarded'); + } + ExecutionTracing::recordCarrierDiagnostic('private=secret'); + + return 7; + }, 'scheduled')); + } finally { + $scope->detach(); + $caller->end(); + $propagator->detach(); + } + $spans = $this->exporter->getSpans(); + self::assertCount($enabled ? 2 : 1, $spans); + foreach ($spans as $span) { + self::assertSame($enabled ? 'filtered' : null, $span->getAttributes() + ->get('durable.trace_context.disposition')); + self::assertSame([], $span->getEvents()); + self::assertNotSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertStringNotContainsString('secret', json_encode( + $span->getAttributes() + ->toArray(), + JSON_THROW_ON_ERROR + )); + } + self::assertSame(0, WorkflowRun::query()->count()); + self::assertSame(0, DB::table('jobs')->count()); + } + + #[DataProvider('dispatchFailures')] + public function testInvalidCapturedParentIsDiagnosedOnAdmissionWithoutAnApplicationSpan(bool $enabled): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $propagator = new class() implements TextMapPropagatorInterface { + public int $injections = 0; + + public function fields(): array + { + return ['traceparent']; + } + + public function inject( + mixed &$carrier, + ?PropagationSetterInterface $setter = null, + ?ContextInterface $context = null, + ): void { + ++$this->injections; + $carrier['traceparent'] = 'private-invalid-header'; + } + + public function extract( + $carrier, + ?PropagationGetterInterface $getter = null, + ?ContextInterface $context = null, + ): ContextInterface { + return $context ?? Context::getCurrent(); + } + }; + $scope = Configurator::create()->withPropagator($propagator)->activate(); + try { + $stub = WorkflowStub::make(TracedWorkflow::class, 'invalid-admission-parent', '0'); + $stub->start(false); + for ($remaining = 4; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + } + $run = WorkflowRun::query()->findOrFail($stub->runId()); + self::assertSame('completed', $run->status->value); + self::assertSame([9007199254740993, null, '雪'], $run->workflowOutput()); + self::assertNull($run->observability_context); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + if ($enabled) { + self::assertGreaterThan(0, $propagator->injections); + } else { + self::assertSame(0, $propagator->injections); + } + $admissions = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.workflow.admission' + )); + self::assertCount($enabled ? 1 : 0, $admissions); + if ($enabled) { + self::assertSame('discarded', $admissions[0]->getAttributes() + ->get('durable.trace_context.disposition')); + self::assertNotSame(StatusCode::STATUS_ERROR, $admissions[0]->getStatus()->getCode()); + self::assertStringNotContainsString('private-invalid-header', json_encode( + $admissions[0]->getAttributes() + ->toArray(), + JSON_THROW_ON_ERROR + )); + } else { + self::assertSame([], $this->exporter->getSpans()); + } + } finally { + $scope->detach(); + } + } + + public static function nonQueueModes(): array + { + return [ + 'poll bookkeeping' => ['poll'], + 'fake execution' => ['fake'], + ]; + } + + #[DataProvider('nonQueueModes')] + public function testNonQueuePathsDoNotClaimQueueProducerSpans(string $mode): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + if ($mode === 'poll') { + config([ + 'workflows.v2.task_dispatch_mode' => 'poll', + ]); + } else { + WorkflowStub::fake(); + } + $stub = WorkflowStub::make(TracedWorkflow::class, 'non-queue-' . $mode, '0'); + $stub->start(false); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame([], array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.task.dispatch' + ))); + $run = WorkflowRun::query()->findOrFail($stub->runId()); + self::assertSame($mode === 'poll' ? 'pending' : 'completed', $run->status->value); + if ($mode === 'fake') { + self::assertSame([9007199254740993, null, '雪'], $stub->refresh()->output()); + } + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + } + + public static function dispatchFailures(): array + { + return [ + 'disabled' => [false], + 'enabled' => [true], + ]; + } + + #[DataProvider('dispatchFailures')] + public function testRealQueueStorageFailureRetainsTheOriginalAdmissionAndFailure(bool $enabled): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + 'queue.connections.database.table' => 'missing_trace_jobs', + ]); + $stub = WorkflowStub::make(TracedWorkflow::class, 'queue-storage-failure', '0'); + try { + $stub->start(false, new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + self::fail('The real queue INSERT must fail.'); + } catch (QueryException $failure) { + self::assertStringContainsString('missing_trace_jobs', $failure->getMessage()); + } + $run = WorkflowRun::query()->where('workflow_instance_id', 'queue-storage-failure')->sole(); + self::assertSame('pending', $run->status->value); + self::assertSame([false], Serializer::unserializeWithCodec($run->payload_codec, $run->arguments)); + self::assertTrue($run->started_at->copy()->addSeconds(60)->equalTo($run->execution_deadline_at)); + self::assertSame(45, $run->run_timeout_seconds); + self::assertTrue($run->started_at->copy()->addSeconds(45)->equalTo($run->run_deadline_at)); + $task = WorkflowTask::query()->where('workflow_run_id', $run->id)->sole(); + self::assertSame('ready', $task->status->value); + self::assertNull($task->last_dispatched_at); + self::assertStringContainsString('missing_trace_jobs', $task->last_dispatch_error); + self::assertSame(0, (int) $task->repair_count); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertSame(0, ActivityAttempt::query()->count()); + self::assertSame('started_new', WorkflowCommand::query()->sole()->outcome->value); + $spans = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.task.dispatch' + )); + self::assertCount($enabled ? 1 : 0, $spans); + foreach ($spans as $span) { + self::assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertSame(QueryException::class, $span->getAttributes()->get('error.type')); + self::assertNull($span->getAttributes()->get('durable.dispatch.outcome')); + self::assertSame([], $span->getEvents()); + self::assertStringNotContainsString( + 'missing_trace_jobs', + json_encode($span->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + } + } + + public function testAdmissionPersistsItsOriginalParentAndDuplicateCannotReplaceIt(): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + $parent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + $scope = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => $parent, + ])->activate(); + try { + $stub = WorkflowStub::make(TracedWorkflow::class, 'captured-original', '0'); + $stub->start(false, new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + } finally { + $scope->detach(); + } + $run = WorkflowRun::query()->findOrFail($stub->runId()); + self::assertSame($parent, $run->observability_context['carrier']['traceparent']); + self::assertSame([false], Serializer::unserializeWithCodec($run->payload_codec, $run->arguments)); + $original = $run->getRawOriginal('observability_context'); + $deadlines = [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')]; + $scope = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => '00-11111111111111111111111111111111-2222222222222222-01', + ])->activate(); + try { + self::assertTrue( + $stub->attemptStart(false, StartOptions::returnExistingActive())->returnedExistingActive() + ); + $run->refresh(); + self::assertSame($original, $run->getRawOriginal('observability_context')); + self::assertSame( + $deadlines, + [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')] + ); + for ($remaining = 4; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + self::assertSame([], ExecutionTracing::current()); + } + } finally { + $scope->detach(); + } + $stub->refresh(); + self::assertSame([9007199254740993, null, '雪'], $stub->output()); + $spans = $this->activitySpans(); + self::assertCount(1, $spans); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $spans[0]->getContext()->getTraceId()); + self::assertSame('00f067aa0ba902b7', $spans[0]->getParentContext()->getSpanId()); + self::assertStringNotContainsString('observability_context', json_encode( + WorkflowHistoryEvent::query()->where('workflow_run_id', $stub->runId())->pluck('payload')->all(), + JSON_THROW_ON_ERROR, + )); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(0, DB::table('failed_jobs')->count()); + } + + public function testCompletedChildOutcomeIsReadFromCommittedRowsAndNeverChangesDurableState(): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + $stub = WorkflowStub::make(ParentTracedWorkflow::class, 'child-outcome-readback', '0'); + $stub->start(); + for ($remaining = 12; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + } + $parent = WorkflowRun::query()->findOrFail($stub->runId()); + self::assertSame('completed', $parent->status->value); + self::assertSame([9007199254740993, null, '雪'], $parent->workflowOutput()); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + $link = WorkflowLink::query()->where('parent_workflow_run_id', $parent->id)->sole(); + $child = WorkflowRun::query()->findOrFail($link->child_workflow_run_id); + self::assertSame('completed', $child->status->value); + $terminal = WorkflowHistoryEvent::query()->where('workflow_run_id', $child->id) + ->where('event_type', 'WorkflowCompleted') + ->sole(); + $childTask = WorkflowTask::query()->findOrFail($terminal->workflow_task_id); + $claim = WorkflowTask::query()->where('workflow_run_id', $parent->id)->latest('id')->firstOrFail(); + $entry = TaskOutcomeCarrier::fromTask($childTask)['outcomes'][0]; + $before = []; + foreach ([ + WorkflowRun::class, + WorkflowTask::class, + WorkflowHistoryEvent::class, + WorkflowLink::class, + ] as $model) { + $before[$model] = $model::query()->orderBy('id')->get()->map( + static fn ($row): array => $row->getRawOriginal(), + )->all(); + } + $outcomes = TaskOutcomeCarrier::forRun($parent, $claim); + self::assertSame($entry + [ + 'task_id' => $childTask->id, + 'child_run_id' => $child->id, + 'child_instance_id' => $child->workflow_instance_id, + 'child_namespace' => $child->namespace, + 'parent_run_id' => $parent->id, + 'parent_namespace' => $parent->namespace, + 'parent_sequence' => $link->sequence, + 'link_id' => $link->id, + ], $outcomes['child:' . $child->id]); + self::assertSame($terminal->id, $entry['event_id']); + foreach ($before as $model => $rows) { + self::assertSame($rows, $model::query()->orderBy('id')->get()->map( + static fn ($row): array => $row->getRawOriginal(), + )->all()); + } + + // A valid carrier bound to a different physical event is not evidence of this child's outcome. + $wrong = TaskOutcomeCarrier::fromTask($childTask); + $wrong['outcomes'][0]['sequence'] = (int) $terminal->sequence + 1; + DB::table($childTask->getTable())->where('id', $childTask->id)->update([ + 'observability_context' => json_encode($wrong, JSON_THROW_ON_ERROR), + ]); + $withoutChild = TaskOutcomeCarrier::forRun($parent, $claim); + self::assertArrayNotHasKey('child:' . $child->id, $withoutChild); + self::assertArrayHasKey( + $parent->historyEvents() + ->where('event_type', 'WorkflowCompleted') + ->sole() +->id, + $withoutChild + ); + self::assertSame([9007199254740993, null, '雪'], $parent->fresh()->workflowOutput()); + self::assertSame([9007199254740993, null, '雪'], $child->fresh()->workflowOutput()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + } + + public function testOutcomeMetadataRollsBackWithItsEventAndDuplicateCannotReplaceIt(): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + $stub = WorkflowStub::make(TracedWorkflow::class, 'outcome-rollback', '0'); + $stub->start(false); + $this->workOnce(); + $task = WorkflowTask::query()->where('task_type', 'activity')->sole(); + [$claim] = ActivityTaskClaimer::claim($task->id, $task->id); + self::assertNotNull($claim); + $prefix = WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( + static fn ($event): array => $event->getRawOriginal(), + )->all(); + $failure = new RuntimeException('deliberate outer outcome rollback'); + $record = static fn (): array => ActivityOutcomeRecorder::record( + $claim->task->id, + $claim->attemptId(), + $claim->attemptNumber(), + [9007199254740993, null, '雪'], + null, + 2, + 0, + ); + ExecutionTracing::activity([ + 'workflow_instance_id' => $claim->run->workflow_instance_id, + 'workflow_run_id' => $claim->run->id, + 'workflow_type' => $claim->run->workflow_type, + 'namespace' => $claim->run->namespace, + 'task_id' => $claim->task->id, + 'activity_id' => $claim->execution->id, + 'activity_attempt_id' => $claim->attemptId(), + 'activity_attempt_number' => $claim->attemptNumber(), + ], null, static function () use ($claim, $record, $failure, $prefix): void { + try { + $claim->task->getConnection() + ->transaction(static function () use ($record, $failure): void { + self::assertTrue($record()['recorded']); + throw $failure; + }); + self::fail('Expected the constructed outer rollback.'); + } catch (RuntimeException $caught) { + self::assertSame($failure, $caught); + } + self::assertSame($prefix, WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( + static fn ($event): array => $event->getRawOriginal(), + )->all()); + $fresh = $claim->task->fresh(); + self::assertNull($fresh->getRawOriginal('observability_context')); + // A later ordinary update must not publish a candidate from the rolled-back event. + $fresh->touch(); + self::assertNull($fresh->fresh()->getRawOriginal('observability_context')); + self::assertTrue($record()['recorded']); + }, enabled: true); + $storedTask = $claim->task->fresh(); + $contexts = TaskOutcomeCarrier::fromTask($storedTask); + self::assertCount(1, $contexts['outcomes']); + $event = WorkflowHistoryEvent::query()->where('event_type', 'ActivityCompleted')->sole(); + self::assertSame($event->id, $contexts['outcomes'][0]['event_id']); + $stored = $storedTask->getRawOriginal('observability_context'); + $history = WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( + static fn ($row): array => $row->getRawOriginal(), + )->all(); + $workflowTask = WorkflowTask::query()->where('workflow_run_id', $claim->run->id) + ->where('task_type', 'workflow') + ->latest('id') + ->firstOrFail(); + $runAttributes = $claim->run->fresh() + ->getRawOriginal(); + $taskAttributes = $storedTask->getRawOriginal(); + self::assertSame([ + $event->id => $contexts['outcomes'][0] + [ + 'task_id' => $storedTask->id, + ], + ], TaskOutcomeCarrier::forRun($claim->run, $workflowTask)); + self::assertSame($taskAttributes, $storedTask->fresh()->getRawOriginal()); + self::assertSame($runAttributes, $claim->run->fresh()->getRawOriginal()); + self::assertSame($history, WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( + static fn ($row): array => $row->getRawOriginal(), + )->all()); + self::assertSame([ + 'recorded' => false, + 'reason' => 'stale_attempt', + 'next_task' => null, + ], $record()); + self::assertSame($stored, $storedTask->fresh()->getRawOriginal('observability_context')); + self::assertSame($history, WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( + static fn ($row): array => $row->getRawOriginal(), + )->all()); + } + + public function testTaskCarrierMigrationKeepsLegacyRowsAndHasNoImplicitAuthority(): void + { + $stub = WorkflowStub::make(TracedWorkflow::class, 'legacy-outcome-task', '0'); + $stub->start(false); + $task = WorkflowTask::query()->sole(); + $migration = require __DIR__ . '/../../../src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php'; + $migration->down(); + $before = (array) DB::table('workflow_tasks')->where('id', $task->id)->sole(); + $migration->up(); + $migration->up(); + $after = (array) DB::table('workflow_tasks')->where('id', $task->id)->sole(); + self::assertArrayHasKey('observability_context', $after); + self::assertNull($after['observability_context']); + unset($after['observability_context']); + self::assertSame($before, $after); + self::assertArrayNotHasKey('observability_context', $task->fresh()->toArray()); + } + + public function testExistingRunMigrationLeavesItsDataUntouchedAndAllowsExecutionWithoutAParent(): void + { + $scope = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + ])->activate(); + try { + $stub = WorkflowStub::make(TracedWorkflow::class, 'legacy-run', '0'); + $stub->start(false); + } finally { + $scope->detach(); + } + self::assertNull(WorkflowRun::query()->findOrFail($stub->runId())->observability_context); + $migration = require __DIR__ . '/../../../src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php'; + $migration->down(); + $before = (array) DB::table('workflow_runs')->where('id', $stub->runId())->sole(); + $migration->up(); + $migration->up(); + $after = (array) DB::table('workflow_runs')->where('id', $stub->runId())->sole(); + self::assertArrayHasKey('observability_context', $after); + self::assertNull($after['observability_context']); + unset($after['observability_context']); + self::assertSame($before, $after); + config([ + 'workflows.tracing.enabled' => true, + ]); + for ($remaining = 4; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + } + self::assertSame([9007199254740993, null, '雪'], $stub->refresh()->output()); + $spans = $this->activitySpans(); + self::assertCount(1, $spans); + self::assertFalse($spans[0]->getParentContext()->isValid()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + } + + public static function coldModes(): array + { + return [ + 'single attempt' => [false], + 'cold retry' => [true], + ]; + } + + public static function unreadableMetadataModes(): iterable + { + foreach (['workflow', 'activity'] as $phase) { + yield $phase . ', enabled' => [$phase, true]; + yield $phase . ', disabled' => [$phase, false]; + } + } + + #[DataProvider('unreadableMetadataModes')] + public function testUnreadableOptionalRunMetadataCannotInterruptClaimedBusinessWork( + string $phase, + bool $enabled + ): void { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $scope = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + ])->activate(); + try { + $stub = WorkflowStub::make(TracedWorkflow::class, 'unreadable-tracing', '0'); + $stub->start(false, new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + } finally { + $scope->detach(); + } + $run = WorkflowRun::query()->findOrFail($stub->runId()); + $preserved = array_flip(['observability_context', 'execution_deadline_at', 'run_deadline_at']); + $original = array_intersect_key($run->getRawOriginal(), $preserved); + $prefix = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + if ($phase === 'activity') { + $this->workOnce(); + } + UnreadableTraceCast::$metadataReads = 0; + WorkflowRun::retrieved(static function (WorkflowRun $retrievedRun): void { + if ($retrievedRun->workflow_instance_id === 'unreadable-tracing') { + $retrievedRun->mergeCasts([ + 'observability_context' => UnreadableTraceCast::class, + ]); + } + }); + for ($remaining = 4; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + self::assertSame([], ExecutionTracing::current()); + } + $run->refresh(); + self::assertSame('completed', $run->status->value); + self::assertSame([9007199254740993, null, '雪'], $run->workflowOutput()); + self::assertSame($original, array_intersect_key($run->getRawOriginal(), $preserved)); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(3, WorkflowTask::query()->where('status', 'completed')->where('attempt_count', 1)->count()); + self::assertSame( + 1, + ActivityAttempt::query()->where('status', 'completed')->where('attempt_number', 1)->count() + ); + self::assertSame(1, WorkflowHistoryEvent::query()->where('event_type', 'ActivityCompleted')->count()); + self::assertSame(1, WorkflowHistoryEvent::query()->where('event_type', 'WorkflowCompleted')->count()); + $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + self::assertSame($prefix, array_slice($history, 0, count($prefix))); + self::assertSame($enabled ? ($phase === 'workflow' ? 3 : 2) : 0, UnreadableTraceCast::$metadataReads); + $spans = $this->exporter->getSpans(); + self::assertCount($enabled ? 7 : 0, $spans); + self::assertCount($enabled ? 3 : 0, array_filter( + $spans, + static fn ($span): bool => $span->getKind() === SpanKind::KIND_PRODUCER + )); + $admissions = array_values(array_filter( + $spans, + static fn ($span): bool => $span->getName() === 'durable.workflow.admission' + )); + self::assertCount($enabled ? 1 : 0, $admissions); + if ($enabled) { + self::assertSame($run->id, $admissions[0]->getAttributes()->get('workflow_run_id')); + self::assertSame('00f067aa0ba902b7', $admissions[0]->getParentContext()->getSpanId()); + } + $processing = array_values(array_filter( + $spans, + static fn ($span): bool => $span->getKind() === SpanKind::KIND_CONSUMER + )); + self::assertCount($enabled ? 3 : 0, $processing); + foreach ($processing as $index => $span) { + self::assertSame($run->id, $span->getAttributes()->get('workflow_run_id')); + self::assertSame($phase === 'activity' && $index === 0, $span->getParentContext()->isValid()); + self::assertSame( + $phase === 'activity' && $index === 0 ? null : 'discarded', + $span->getAttributes() + ->get('durable.trace_context.disposition') + ); + self::assertNotSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertStringNotContainsString( + 'optional telemetry cast failed', + json_encode($span->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + } + self::assertCount( + 1, + array_filter( + TracedActivity::$observations, + static fn (array $observation): bool => $observation[0] === 'callback' + ) + ); + } + + public static function unavailableTracingConfiguration(): array + { + return [ + 'disabled control' => [null, false], + 'unavailable tracing key' => ['workflows.tracing.enabled', false], + 'unavailable baggage allowlist' => ['workflows.tracing.baggage_allowlist', true], + ]; + } + + #[DataProvider('unavailableTracingConfiguration')] + public function testOptionalTracingConfigurationCannotReplaceTheBusinessFailure( + ?string $unavailable, + bool $enabled + ): void { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $stub = WorkflowStub::make(FailingTracedWorkflow::class, 'unavailable-tracing-config', '0'); + $stub->start(new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + $run = WorkflowRun::query()->findOrFail($stub->runId()); + $preserved = array_flip(['observability_context', 'execution_deadline_at', 'run_deadline_at']); + $original = array_intersect_key($run->getRawOriginal(), $preserved); + $prefix = $run->historyEvents() + ->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + $configuration = $this->app['config']; + $this->app->instance('config', $this->unavailableConfiguration($unavailable)); + try { + $this->workOnce(); + } finally { + $this->app->instance('config', $configuration); + } + $run->refresh(); + self::assertSame('failed', $run->status->value); + self::assertSame('application detail must not be exported', $run->failures()->sole()->message); + self::assertSame($original, array_intersect_key($run->getRawOriginal(), $preserved)); + self::assertSame(1, $run->historyEvents()->where('event_type', 'WorkflowFailed')->count()); + self::assertSame($prefix, array_slice($run->historyEvents()->orderBy('sequence')->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(), 0, count($prefix))); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertCount($enabled ? 3 : 0, $this->exporter->getSpans()); + } + + #[DataProvider('unavailableTracingConfiguration')] + public function testOptionalConfigurationCannotBlockAdmissionTimerOrActivity( + ?string $unavailable, + bool $enabled + ): void { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $configuration = $this->app['config']; + $this->app->instance('config', $this->unavailableConfiguration($unavailable)); + try { + $stub = WorkflowStub::make(TimerTracedWorkflow::class, 'unavailable-config-completion', '0'); + $stub->start(new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + $run = WorkflowRun::query()->findOrFail($stub->runId()); + $deadlines = [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')]; + $prefix = $run->historyEvents() + ->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + $this->workOnce(); + sleep(2); + for ($remaining = 4; $remaining > 0; --$remaining) { + $this->workOnce(); + } + $run->refresh(); + self::assertSame('completed', $run->status->value); + self::assertSame([9007199254740993, null, '雪'], $stub->refresh()->output()); + self::assertSame( + $deadlines, + [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')] + ); + self::assertSame($prefix, array_slice($run->historyEvents()->orderBy('sequence')->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(), 0, count($prefix))); + self::assertSame(1, $run->historyEvents()->where('event_type', 'WorkflowCompleted')->count()); + self::assertSame(1, $run->historyEvents()->where('event_type', 'ActivityCompleted')->count()); + self::assertSame(1, $run->historyEvents()->where('event_type', 'TimerFired')->count()); + self::assertSame(5, WorkflowTask::query()->where('status', 'completed')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertCount($enabled ? 11 : 0, $this->exporter->getSpans()); + } finally { + $this->app->instance('config', $configuration); + } + } + + #[DataProvider('dispatchFailures')] + public function testScheduledAdmissionKeepsProcessorHooksOutsideTheScheduleTransaction(bool $enabled): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $admissionContexts = []; + $this->app['events']->listen(WorkflowStarted::class, static function () use (&$admissionContexts): void { + $admissionContexts[] = ExecutionTracing::current(); + }); + $processor = new ScheduleTracingProcessor($this->exporter); + $provider = new TracerProvider($processor); + $instrumentation = Configurator::create()->withTracerProvider($provider)->activate(); + try { + $creator = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ])->activate(); + try { + $schedule = ScheduleManager::create( + scheduleId: 'traced-manual-schedule', + workflowClass: TracedWorkflow::class, + cronExpression: '* * * * *', + arguments: [false], + connection: 'database', + queue: 'default', + namespace: '0', + executionTimeoutSeconds: 60, + runTimeoutSeconds: 45, + ); + } finally { + $creator->detach(); + } + $parent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + $caller = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => $parent, + ])->activate(); + try { + $instanceId = ScheduleManager::trigger($schedule); + } finally { + $caller->detach(); + } + self::assertSame(0, DB::transactionLevel()); + self::assertNotNull($instanceId); + $run = WorkflowRun::query()->where('workflow_instance_id', $instanceId)->sole(); + self::assertCount(1, $admissionContexts); + if ($enabled) { + self::assertSame($instanceId, $admissionContexts[0]['workflow_instance_id'] ?? null); + self::assertSame($run->workflow_type, $admissionContexts[0]['workflow_type'] ?? null); + self::assertSame('0', $admissionContexts[0]['namespace'] ?? null); + self::assertSame('traced-manual-schedule', $admissionContexts[0]['schedule_id'] ?? null); + } else { + self::assertSame([], $admissionContexts[0]); + } + $preserved = array_intersect_key($run->getRawOriginal(), array_flip([ + 'observability_context', 'execution_deadline_at', 'run_deadline_at', + ])); + self::assertSame($enabled ? $parent : null, $run->observability_context['carrier']['traceparent'] ?? null); + $prefix = $run->historyEvents() + ->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + for ($remaining = 3; $remaining > 0; --$remaining) { + $this->workOnce(); + } + $run->refresh(); + $schedule->refresh(); + self::assertSame('completed', $run->status->value); + self::assertSame([9007199254740993, null, '雪'], Serializer::unserializeWithCodec( + $run->payload_codec, + $run->output + )); + self::assertSame($preserved, array_intersect_key($run->getRawOriginal(), $preserved)); + self::assertSame($prefix, array_slice($run->historyEvents()->orderBy('sequence')->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(), 0, count($prefix))); + self::assertSame(1, (int) $schedule->fires_count); + self::assertSame($instanceId, $schedule->latest_workflow_instance_id); + self::assertSame(1, $run->historyEvents()->where('event_type', 'ScheduleTriggered')->count()); + self::assertSame(1, $run->historyEvents()->where('event_type', 'WorkflowCompleted')->count()); + self::assertSame(1, $run->historyEvents()->where('event_type', 'ActivityCompleted')->count()); + self::assertSame(3, WorkflowTask::query()->where('status', 'completed')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertCount($enabled ? 7 : 0, $this->exporter->getSpans()); + $triggers = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $triggers); + if ($enabled) { + self::assertSame('00f067aa0ba902b7', $triggers[0]->getParentContext()->getSpanId()); + self::assertSame($run->id, $triggers[0]->getAttributes()->get('workflow_run_id')); + self::assertSame('traced-manual-schedule', $triggers[0]->getAttributes()->get('schedule_id')); + self::assertSame('triggered', $triggers[0]->getAttributes()->get('durable.schedule.outcome')); + self::assertSame('manual', $triggers[0]->getAttributes()->get('durable.schedule.trigger_kind')); + } + self::assertSame(array_fill(0, count($processor->transactionLevels), 0), $processor->transactionLevels); + } finally { + $instrumentation->detach(); + $provider->shutdown(); + } + } + + #[DataProvider('dispatchFailures')] + public function testScheduledQueueStorageFailurePreservesItsCommittedAdmission(bool $enabled): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + 'queue.connections.database.table' => 'missing_trace_jobs', + ]); + $schedule = ScheduleManager::create( + scheduleId: 'schedule-queue-failure', + workflowClass: TracedWorkflow::class, + cronExpression: '* * * * *', + arguments: [false], + namespace: '0', + executionTimeoutSeconds: 60, + runTimeoutSeconds: 45 + ); + try { + ScheduleManager::trigger($schedule); + self::fail('The actual post-commit queue INSERT must fail.'); + } catch (QueryException $failure) { + self::assertStringContainsString('missing_trace_jobs', $failure->getMessage()); + } + $schedule->refresh(); + $run = WorkflowRun::query()->sole(); + $task = WorkflowTask::query()->sole(); + self::assertSame(1, (int) $schedule->fires_count); + self::assertSame($run->workflow_instance_id, $schedule->latest_workflow_instance_id); + self::assertSame('pending', $run->status->value); + self::assertSame('ready', $task->status->value); + self::assertNull($task->last_dispatched_at); + self::assertStringContainsString('missing_trace_jobs', $task->last_dispatch_error); + self::assertSame(0, (int) $task->repair_count); + self::assertTrue($run->started_at->copy()->addSeconds(60)->equalTo($run->execution_deadline_at)); + self::assertTrue($run->started_at->copy()->addSeconds(45)->equalTo($run->run_deadline_at)); + self::assertSame(0, ActivityAttempt::query()->count()); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + $triggers = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $triggers); + if ($enabled) { + self::assertSame(StatusCode::STATUS_ERROR, $triggers[0]->getStatus()->getCode()); + self::assertSame(QueryException::class, $triggers[0]->getAttributes()->get('error.type')); + self::assertNull($triggers[0]->getAttributes()->get('durable.schedule.outcome')); + } + } + + public static function queuedScheduleKinds(): array + { + return [ + 'buffered traced' => ['buffered', true], + 'buffered disabled' => ['buffered', false], + 'backfill traced' => ['backfill', true], + 'backfill disabled' => ['backfill', false], + ]; + } + + #[DataProvider('queuedScheduleKinds')] + public function testBufferedAndBackfilledRunsKeepTheirAtomicScheduleState(string $kind, bool $enabled): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $processor = new ScheduleTracingProcessor($this->exporter); + $provider = new TracerProvider($processor); + $scope = Configurator::create()->withTracerProvider($provider)->activate(); + try { + $schedule = ScheduleManager::create( + scheduleId: 'queued-' . $kind, + workflowClass: TracedWorkflow::class, + cronExpression: '* * * * *', + arguments: [false], + overlapPolicy: $kind === 'buffered' ? ScheduleOverlapPolicy::BufferOne : ScheduleOverlapPolicy::AllowAll, + namespace: '0', + executionTimeoutSeconds: 60, + runTimeoutSeconds: 45 + ); + if ($kind === 'buffered') { + self::assertNotNull(ScheduleManager::trigger($schedule)); + self::assertNull(ScheduleManager::trigger($schedule)); + } else { + $from = now() + ->startOfMinute() + ->subMinutes(2); + $receipts = ScheduleManager::backfill($schedule, $from, $from->copy()->addMinute()); + self::assertCount(1, $receipts); + self::assertNotNull($receipts[0]['instance_id']); + } + $first = WorkflowRun::query()->sole(); + $original = $first->getRawOriginal(); + $prefix = $first->historyEvents() + ->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + for ($remaining = 3; $remaining > 0; --$remaining) { + $this->workOnce(); + } + if ($kind === 'buffered') { + $receipts = ScheduleManager::tick(1); + self::assertSame('drained', $receipts[0]['outcome']); + for ($remaining = 3; $remaining > 0; --$remaining) { + $this->workOnce(); + } + } + $schedule->refresh(); + $first->refresh(); + foreach (['observability_context', 'execution_deadline_at', 'run_deadline_at'] as $field) { + self::assertSame($original[$field], $first->getRawOriginal($field)); + } + self::assertSame($prefix, array_slice($first->historyEvents()->orderBy('sequence')->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(), 0, count($prefix))); + $runCount = $kind === 'buffered' ? 2 : 1; + self::assertSame($runCount, (int) $schedule->fires_count); + self::assertSame($runCount, WorkflowRun::query()->where('status', 'completed')->count()); + foreach (WorkflowRun::query()->get() as $run) { + self::assertSame([9007199254740993, null, '雪'], $run->workflowOutput()); + self::assertTrue($run->started_at->copy()->addSeconds(60)->equalTo($run->execution_deadline_at)); + self::assertTrue($run->started_at->copy()->addSeconds(45)->equalTo($run->run_deadline_at)); + } + self::assertSame($runCount, ActivityAttempt::query()->where('status', 'completed')->count()); + self::assertSame($runCount * 3, WorkflowTask::query()->where('status', 'completed')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + $triggers = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? ($kind === 'buffered' ? 3 : 1) : 0, $triggers); + if ($enabled) { + $last = $triggers[count($triggers) - 1]; + self::assertSame($kind, $last->getAttributes()->get('durable.schedule.trigger_kind')); + self::assertSame( + $kind === 'buffered' ? 'drained' : 'triggered', + $last->getAttributes() + ->get('durable.schedule.outcome') + ); + } + self::assertSame(array_fill(0, count($processor->transactionLevels), 0), $processor->transactionLevels); + } finally { + $scope->detach(); + $provider->shutdown(); + } + } + + public function testFailedWorkflowExportsItsProcessingSegmentOutsideTheDatabaseTransaction(): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + $processor = new class($this->exporter) extends SimpleSpanProcessor { + public array $transactionLevels = []; + + public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentContext): void + { + $this->transactionLevels[] = DB::transactionLevel(); + } + + public function onEnd(ReadableSpanInterface $span): void + { + $this->transactionLevels[] = DB::transactionLevel(); + parent::onEnd($span); + } + }; + $provider = new TracerProvider($processor); + $scope = Configurator::create()->withTracerProvider($provider)->activate(); + try { + $stub = WorkflowStub::make(FailingTracedWorkflow::class, 'failed-workflow', '0'); + $stub->start(); + $this->workOnce(); + self::assertSame('failed', WorkflowRun::query()->findOrFail($stub->runId())->status->value); + self::assertSame([0, 0, 0, 0, 0, 0], $processor->transactionLevels); + $spans = $this->exporter->getSpans(); + self::assertSame( + ['durable.task.dispatch', 'durable.workflow.admission', 'durable.workflow.task'], + array_map(static fn ($span): string => $span->getName(), $spans) + ); + self::assertSame('started_new', $spans[1]->getAttributes()->get('durable.command.outcome')); + self::assertSame(StatusCode::STATUS_ERROR, $spans[2]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[2]->getAttributes()->get('error.type')); + self::assertSame($stub->runId(), FailingTracedWorkflow::$fields['workflow_run_id']); + self::assertSame($spans[2]->getContext()->getSpanId(), FailingTracedWorkflow::$fields['span_id']); + self::assertSame([], ExecutionTracing::current()); + self::assertSame(0, DB::table('failed_jobs')->count()); + } finally { + $scope->detach(); + $provider->shutdown(); + } + } + + public function testCommandCarrierMigrationPreservesLegacyRowsAndKeepsMetadataOutOfPublicContext(): void + { + $stub = WorkflowStub::make(TracedWorkflow::class, 'legacy-command', '0'); + $stub->start(false); + $migration = require __DIR__ . '/../../../src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php'; + $migration->down(); + $before = (array) DB::table('workflow_commands')->sole(); + $migration->up(); + $migration->up(); + $after = (array) DB::table('workflow_commands')->sole(); + self::assertArrayHasKey('observability_context', $after); + self::assertNull($after['observability_context']); + unset($after['observability_context']); + self::assertSame($before, $after); + $command = WorkflowCommand::query()->sole(); + $context = $command->commandContext(); + $publicContext = $command->publicContext(); + $command->observability_context = [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => [ + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ], + ]; + self::assertSame($context, $command->commandContext()); + self::assertSame($publicContext, $command->publicContext()); + self::assertArrayNotHasKey('observability_context', $command->toArray()); + } + + #[DataProvider('coldModes')] + public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool $retry): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $run = static function (string $mode) use ($directory, $retry): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, $retry ? 'retry' : 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ]); + $process->setTimeout(30); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), $mode . ': ' . substr($process->getOutput(), 0, 2048)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $run('admit'); + $duplicate = $run('duplicate'); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $duplicate[$key]); + } + $workflow = $run('work'); + $activity = $run('work'); + $retried = $retry ? $run('work') : null; + $completion = $run('work'); + $workerPids = [$workflow['pid'], $activity['pid'], $completion['pid']]; + if ($retried !== null) { + $workerPids[] = $retried['pid']; + self::assertCount(1, $retried['execution_spans']); + self::assertSame( + $activity['execution_spans'][0]['fields']['activity_id'], + $retried['execution_spans'][0]['fields']['activity_id'] + ); + self::assertNotSame( + $activity['execution_spans'][0]['fields']['activity_attempt_id'], + $retried['execution_spans'][0]['fields']['activity_attempt_id'] + ); + self::assertNotSame( + $activity['execution_spans'][0]['span_id'], + $retried['execution_spans'][0]['span_id'] + ); + self::assertSame( + $activity['execution_spans'][0]['trace_id'], + $retried['execution_spans'][0]['trace_id'] + ); + self::assertSame( + $activity['execution_spans'][0]['parent_id'], + $retried['execution_spans'][0]['parent_id'] + ); + } + self::assertCount($retry ? 4 : 3, array_unique($workerPids)); + foreach ([$workflow, $completion] as $segment) { + self::assertCount(1, $segment['execution_spans']); + self::assertSame('durable.workflow.task', $segment['execution_spans'][0]['name']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $segment['execution_spans'][0]['trace_id']); + self::assertSame('00f067aa0ba902b7', $segment['execution_spans'][0]['parent_id']); + self::assertSame($admitted['run_id'], $segment['execution_spans'][0]['fields']['workflow_run_id']); + self::assertArrayNotHasKey('activity_attempt_id', $segment['execution_spans'][0]['fields']); + } + self::assertNotSame( + $workflow['execution_spans'][0]['span_id'], + $completion['execution_spans'][0]['span_id'] + ); + self::assertNotSame( + $workflow['execution_spans'][0]['fields']['task_id'], + $completion['execution_spans'][0]['fields']['task_id'] + ); + self::assertCount(1, $activity['execution_spans']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $activity['execution_spans'][0]['trace_id']); + self::assertSame('00f067aa0ba902b7', $activity['execution_spans'][0]['parent_id']); + $finished = $run('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame($retry ? 4 : 3, $finished['tasks']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_values( + array_filter($records, static fn (array $record): bool => $record['message'] === 'callback') + ); + self::assertCount($retry ? 2 : 1, $callbacks); + foreach ($callbacks as $callback) { + self::assertSame($admitted['run_id'], $callback['context']['workflow_run_id']); + self::assertSame($activity['execution_spans'][0]['trace_id'], $callback['context']['trace_id']); + self::assertArrayHasKey('activity_attempt_id', $callback['context']); + self::assertSame('0', $callback['context']['namespace']); + $constructed = array_values( + array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'constructor' && ($record['context']['activity_attempt_id'] ?? null) === $callback['context']['activity_attempt_id'] + ) + ); + self::assertCount(1, $constructed); + self::assertSame($callback['context'], $constructed[0]['context']); + $dependencies = array_values( + array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'dependency' && ($record['context']['activity_attempt_id'] ?? null) === $callback['context']['activity_attempt_id'] + ) + ); + self::assertCount(2, $dependencies); + foreach ($dependencies as $dependency) { + self::assertSame($callback['context'], $dependency['context']); + } + } + self::assertCount( + $retry ? 4 : 3, + array_filter($records, static fn (array $record): bool => $record['message'] === 'worker outside') + ); + foreach ($records as $record) { + if ($record['message'] === 'worker outside') { + self::assertArrayNotHasKey('workflow_run_id', $record['context']); + } elseif (isset($record['context']['activity_attempt_id'])) { + self::assertSame($admitted['run_id'], $record['context']['workflow_run_id']); + self::assertSame($activity['execution_spans'][0]['trace_id'], $record['context']['trace_id']); + } + } + $workflowLogs = array_values(array_filter( + $records, + static fn (array $record): bool => str_starts_with($record['message'], 'workflow ') + )); + self::assertCount(9, $workflowLogs); + $segments = []; + foreach ([$workflow, $completion] as $segment) { + $span = $segment['execution_spans'][0]; + self::assertArrayHasKey('durable.task.queue_wait_seconds', $span['fields']); + self::assertGreaterThanOrEqual(0, $span['fields']['durable.task.queue_wait_seconds']); + $correlation = $span['fields']; + unset($correlation['durable.task.queue_wait_seconds']); + $segments[$span['fields']['task_id']] = $correlation + [ + 'trace_id' => $span['trace_id'], + 'span_id' => $span['span_id'], + ]; + } + foreach ($workflowLogs as $record) { + self::assertArrayNotHasKey('durable.task.queue_wait_seconds', $record['context']); + self::assertArrayHasKey($record['context']['task_id'], $segments); + self::assertSame($segments[$record['context']['task_id']], $record['context']); + } + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + #[DataProvider('modes')] + public function testDatabaseQueueScopesDependenciesAndPreservesOriginalTaskRetries(bool $enabled, bool $retry): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $stub = WorkflowStub::make(TracedWorkflow::class, 'tracing-original', '0'); + $stub->start($retry); + $this->workOnce(); + // Scheduling can inspect an activity's policy. Only actual execution is traced. + TracedActivity::$observations = []; + for ($remaining = 6; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + self::assertSame([], ExecutionTracing::current()); + } + $stub->refresh(); + self::assertSame([9007199254740993, null, '雪'], $stub->output()); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame($retry ? 2 : 1, ActivityAttempt::query()->count()); + $spans = $this->activitySpans(); + self::assertCount($enabled ? ($retry ? 2 : 1) : 0, $spans); + $attempts = []; + foreach (TracedActivity::$observations as [$phase, $fields]) { + if (! $enabled) { + self::assertSame([], $fields); + continue; + } + self::assertSame($stub->runId(), $fields['workflow_run_id']); + self::assertSame('tracing-original', $fields['workflow_instance_id']); + self::assertSame('0', $fields['namespace']); + self::assertNotEmpty($fields['trace_id']); + self::assertNotEmpty($fields['span_id']); + $attempts[$fields['activity_attempt_id']][$phase] = $fields; + } + if ($enabled) { + self::assertCount($retry ? 2 : 1, $attempts); + foreach ($attempts as $phases) { + self::assertSame($phases['dependency'], $phases['constructor']); + self::assertSame($phases['constructor'], $phases['callback']); + } + if ($retry) { + self::assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[0]->getAttributes()->get('error.type')); + self::assertNotSame($spans[0]->getContext()->getSpanId(), $spans[1]->getContext()->getSpanId()); + self::assertSame( + $spans[0]->getAttributes()->get('activity_id'), + $spans[1]->getAttributes()->get('activity_id') + ); + } + } + } + + public static function localModes(): array + { + return [ + 'disabled' => [false, false, false], + 'enabled' => [true, false, false], + 'legacy' => [true, false, true], + 'retry' => [true, true, false], + ]; + } + + #[DataProvider('localModes')] + public function testLocalAttemptsKeepTheHostingTaskParentAcrossRetryAndHistoryReplay( + bool $enabled, + bool $retry, + bool $legacy, + ): void { + config([ + 'workflows.tracing.enabled' => $enabled && ! $legacy, + ]); + $stub = WorkflowStub::make(LocalTracedWorkflow::class, 'local-tracing-original', '0'); + $stub->start($retry, new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + $run = WorkflowRun::query()->findOrFail($stub->runId()); + $deadlines = [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')]; + if ($legacy) { + self::assertNull($run->observability_context); + } + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + for ($remaining = 10; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + self::assertSame([], ExecutionTracing::current()); + } + self::assertSame('completed', $run->fresh()->status->value); + self::assertSame([9007199254740993, null, '雪'], $stub->refresh()->output()); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(0, WorkflowTask::query()->where('task_type', 'activity')->count()); + $run->refresh(); + self::assertSame($deadlines, [ + $run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at'), + ]); + $attempts = ActivityAttempt::query()->get()->keyBy('id'); + self::assertCount($retry ? 2 : 1, $attempts); + $execution = ActivityExecution::query()->sole(); + $spans = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.local_activity.attempt' + )); + self::assertCount($enabled ? ($retry ? 2 : 1) : 0, $spans); + $parents = []; + foreach ($this->exporter->getSpans() as $span) { + if ($span->getName() === 'durable.workflow.task') { + $parents[$span->getContext()->getSpanId()] = $span; + } + } + foreach ($spans as $span) { + self::assertSame(SpanKind::KIND_INTERNAL, $span->getKind()); + $fields = $span->getAttributes(); + $attempt = $attempts[$fields->get('activity_attempt_id')]; + self::assertSame($execution->id, $fields->get('activity_id')); + self::assertSame($attempt->attempt_number, $fields->get('activity_attempt_number')); + self::assertSame($attempt->workflow_task_id, $fields->get('task_id')); + self::assertNull($fields->get('durable.task.queue_wait_seconds')); + $parent = $parents[$span->getParentContext()->getSpanId()]; + self::assertSame($span->getContext()->getTraceId(), $parent->getContext()->getTraceId()); + self::assertSame($fields->get('task_id'), $parent->getAttributes()->get('task_id')); + } + $callbacks = array_values(array_filter( + TracedActivity::$observations, + static fn (array $observation): bool => $observation[0] === 'callback' + )); + self::assertCount($retry ? 2 : 1, $callbacks); + foreach ($callbacks as [, $fields]) { + if ($enabled) { + self::assertSame($execution->id, $fields['activity_id']); + self::assertArrayHasKey($fields['activity_attempt_id'], $attempts->all()); + } else { + self::assertSame([], $fields); + } + } + foreach (LocalTracedWorkflow::$scopes as [$before, $after]) { + self::assertSame($before, $after); + self::assertArrayNotHasKey('activity_attempt_id', $after); + } + if ($enabled) { + foreach ($attempts as $attempt) { + $observed = array_column(array_filter( + TracedActivity::$observations, + static fn (array $entry): bool => ($entry[1]['activity_attempt_id'] ?? null) === $attempt->id, + ), 0); + self::assertContains('constructor', $observed); + self::assertContains('dependency', $observed); + self::assertContains('callback', $observed); + } + } + if ($enabled && $retry) { + self::assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[0]->getAttributes()->get('error.type')); + self::assertNotSame($spans[0]->getContext()->getSpanId(), $spans[1]->getContext()->getSpanId()); + } + self::assertStringNotContainsString('observability_context', json_encode( + WorkflowHistoryEvent::query()->pluck('payload')->all(), + JSON_THROW_ON_ERROR, + )); + } + + public static function unrelatedLocalParents(): array + { + return [ + 'other instance' => ['workflow_instance_id', true], + 'other run' => ['workflow_run_id', true], + 'other namespace' => ['namespace', true], + 'other task' => ['task_id', true], + 'legacy without stored parent' => ['task_id', false], + ]; + } + + #[DataProvider('unrelatedLocalParents')] + public function testLocalAttemptNeverInheritsAnUnrelatedHostingTask(string $mismatch, bool $stored): void + { + $fields = [ + 'workflow_instance_id' => 'local-instance', + 'workflow_run_id' => 'local-run', + 'namespace' => '0', + 'task_id' => 'local-task', + ]; + $other = array_replace($fields, [ + $mismatch => 'unrelated', + ]); + $carrier = $stored ? [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => [ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + ], + ] : null; + ExecutionTracing::workflow($other, null, static function () use ($fields, $carrier): void { + $before = ExecutionTracing::current(); + self::assertSame('original result', ExecutionTracing::localActivity( + $fields, + $carrier, + static function () use ($fields): string { + self::assertSame($fields['task_id'], ExecutionTracing::current()['task_id']); + + return 'original result'; + }, + enabled: true, + )); + self::assertSame($before, ExecutionTracing::current()); + }, enabled: true); + $spans = $this->exporter->getSpans(); + self::assertCount(2, $spans); + $local = $spans[0]; + $ambient = $spans[1]; + self::assertSame('durable.local_activity.attempt', $local->getName()); + self::assertNotSame($ambient->getContext()->getSpanId(), $local->getParentContext()->getSpanId()); + if ($stored) { + self::assertSame('00f067aa0ba902b7', $local->getParentContext()->getSpanId()); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $local->getContext()->getTraceId()); + } else { + self::assertFalse($local->getParentContext()->isValid()); + } + } + + public function testMockedLocalActivityDoesNotClaimACallbackSpan(): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + $expected = [9007199254740993, null, '雪']; + WorkflowStub::mock(TracedActivity::class, $expected); + $stub = WorkflowStub::make(LocalTracedWorkflow::class, 'mocked-local-tracing', '0'); + $stub->start(false, false); + self::assertSame($expected, $stub->refresh()->output()); + self::assertSame([], array_values(array_filter( + TracedActivity::$observations, + static fn (array $entry): bool => $entry[0] === 'callback', + ))); + self::assertSame([], array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.local_activity.attempt', + ))); + } + + protected function getPackageProviders($app): array + { + return [TestDatabaseServiceProvider::class, WorkflowServiceProvider::class]; + } + + protected function defineEnvironment($app): void + { + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + $app['config']->set('workflows.storage.connection', 'testing'); + $app['config']->set('workflows.v1.enabled', false); + $app['config']->set('workflows.v2.task_dispatch_mode', 'queue'); + $app['config']->set('workflows.v2.compatibility.namespace', '0'); + $app['config']->set('queue.default', 'database'); + $app['config']->set('queue.connections.database', [ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'default', + 'retry_after' => 90, + ]); + $app['config']->set('queue.failed', [ + 'driver' => 'database-uuids', + 'database' => 'testing', + 'table' => 'failed_jobs', + ]); + $app['config']->set('cache.default', 'array'); + $app['config']->set('logging.default', 'stderr'); + } + + private function unavailableConfiguration(?string $unavailable): Repository + { + return new class($this->app['config']->all(), $unavailable) extends Repository { + public function __construct( + array $items, + private readonly ?string $unavailable + ) { + parent::__construct($items); + } + + public function get($key, $default = null) + { + if ($key === $this->unavailable) { + throw new RuntimeException('Optional tracing setting is unavailable'); + } + + return parent::get($key, $default); + } + }; + } + + private function workOnce(): void + { + self::assertSame(0, Artisan::call('queue:work', [ + 'connection' => 'database', + '--queue' => 'default', + '--once' => true, + '--tries' => 1, + '--timeout' => 30, + ])); + } + + private function activitySpans(): array + { + return array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.activity.attempt' + )); + } +} + +final class ScheduleTracingProcessor extends SimpleSpanProcessor +{ + public array $transactionLevels = []; + + public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentContext): void + { + $this->transactionLevels[] = DB::transactionLevel(); + parent::onStart($span, $parentContext); + } + + public function onEnd(ReadableSpanInterface $span): void + { + $this->transactionLevels[] = DB::transactionLevel(); + parent::onEnd($span); + } +} + +final class UnreadableTraceCast implements CastsAttributes +{ + public static int $metadataReads = 0; + + public function get(Model $model, string $key, mixed $value, array $attributes): mixed + { + ++self::$metadataReads; + throw new RuntimeException('optional application telemetry cast failed'); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): mixed + { + return $value; + } +} + +final class ParentTracedWorkflow extends Workflow +{ + public function handle(): array + { + return self::child(TracedWorkflow::class, false); + } +} + +final class TracedWorkflow extends Workflow +{ + public function handle(bool $retry): array + { + return self::activity(TracedActivity::class, $retry); + } +} + +final class TimerTracedWorkflow extends Workflow +{ + public function handle(): array + { + self::timer(1); + + return self::activity(TracedActivity::class, false); + } +} + +final class LocalTracedWorkflow extends Workflow +{ + public static array $scopes = []; + + public function handle(bool $retry, bool $wait = true): array + { + $before = ExecutionTracing::current(); + try { + $result = self::localActivity(TracedActivity::class, $retry); + } finally { + self::$scopes[] = [$before, ExecutionTracing::current()]; + } + if ($wait) { + self::timer(1); + } + + return $result; + } +} + +final class FailingTracedWorkflow extends Workflow +{ + public static array $fields = []; + + public function handle(): never + { + self::$fields = ExecutionTracing::current(); + throw new RuntimeException('application detail must not be exported'); + } +} + +final class TracedDependency +{ + public function __construct() + { + TracedActivity::$observations[] = ['dependency', ExecutionTracing::current()]; + } +} + +final class TracedActivity extends Activity +{ + public static array $observations = []; + + public int $tries = 2; + + public function __construct(TracedDependency $dependency) + { + self::$observations[] = ['constructor', ExecutionTracing::current()]; + } + + public function backoff(): int + { + return 0; + } + + public function handle(bool $retry, TracedDependency $dependency): array + { + self::$observations[] = ['callback', ExecutionTracing::current()]; + if ($retry && $this->attemptCount() === 1) { + throw new RuntimeException('business failure must not become exported text'); + } + + return [9007199254740993, null, '雪']; + } +} diff --git a/tests/Unit/V2/AdmissionExecutionTracingTest.php b/tests/Unit/V2/AdmissionExecutionTracingTest.php new file mode 100644 index 000000000..ce39e16a1 --- /dev/null +++ b/tests/Unit/V2/AdmissionExecutionTracingTest.php @@ -0,0 +1,175 @@ + [true, false], + 'disabled control' => [false, false], + 'caller without parent' => [true, true], + ]; + } + + #[DataProvider('modes')] + public function testOrdinaryAdmissionDecisionsPreserveTheCallerAndOriginalDurableParent( + bool $enabled, + bool $withoutParent, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-admission-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $enabled, $withoutParent): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_ADMISSION' => 'true', + 'DW_TEST_TRACING_ADMISSION_NO_PARENT' => $withoutParent ? 'true' : 'false', + 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + self::assertTrue($result['admission']['context_preserved']); + + return $result; + }; + try { + $admitted = $worker('admit'); + $duplicate = $worker('duplicate'); + $rejected = $worker('reject'); + $workers = [$worker('work'), $worker('work'), $worker('work')]; + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(1, $finished['admission']['run_count']); + self::assertSame(3, $finished['tasks']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + foreach ([$duplicate, $rejected, $finished] as $observation) { + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $observation[$field]); + } + self::assertSame($admitted['admission']['history_hashes'], array_slice( + $observation['admission']['history_hashes'], + 0, + count($admitted['admission']['history_hashes']) + )); + } + foreach ([$admitted, $duplicate, $rejected] as $index => $attempt) { + self::assertSame( + $enabled ? array_fill(0, 1 + count($attempt['spans']) - count($attempt['execution_spans']), 0) : [], + $attempt['admission']['capture_transaction_levels'] + ); + self::assertCount($enabled ? 1 : 0, $attempt['execution_spans']); + self::assertSame( + ['started_new', 'returned_existing_active', 'rejected_duplicate'][$index], + $attempt['admission']['result']['outcome'] + ); + if (! $enabled) { + self::assertSame([], $attempt['admission']['boundaries']); + continue; + } + self::assertSame([ + [ + 'event' => 'start', + 'transaction_level' => 0, + ], + [ + 'event' => 'end', + 'transaction_level' => 0, + ], + ], $attempt['admission']['boundaries']); + $span = $attempt['execution_spans'][0]; + self::assertSame('durable.workflow.admission', $span['name']); + self::assertSame(SpanKind::KIND_INTERNAL, $span['kind']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + self::assertSame('cold-tracing', $span['fields']['workflow_instance_id']); + self::assertSame('0', $span['fields']['namespace']); + self::assertSame($attempt['admission']['result']['command_id'], $span['fields']['workflow_command_id']); + self::assertSame( + $attempt['admission']['result']['outcome'], + $span['fields']['durable.command.outcome'] + ); + self::assertSame($index === 2 ? 'rejected' : 'accepted', $span['fields']['durable.command.status']); + if ($index === 2) { + self::assertSame('instance_already_started', $span['fields']['durable.command.rejection_reason']); + } + if ($withoutParent) { + self::assertSame('0000000000000000', $span['parent_id']); + } else { + self::assertSame($index === 0 ? '4bf92f3577b34da6a3ce929d0e0e4736' + : 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $span['trace_id']); + self::assertSame($index === 0 ? '00f067aa0ba902b7' : 'bbbbbbbbbbbbbbbb', $span['parent_id']); + } + } + self::assertCount(1, $admitted['admission']['callbacks']); + $callback = $admitted['admission']['callbacks'][0]; + self::assertSame(0, $callback['transaction_level']); + self::assertSame('caller-scope', $callback['foreign']); + if ($enabled) { + self::assertSame($admitted['execution_spans'][0]['span_id'], $callback['span_id']); + self::assertSame('cold-tracing', $callback['fields']['workflow_instance_id']); + } else { + self::assertSame([], $callback['fields']); + } + self::assertSame([], $duplicate['admission']['callbacks']); + self::assertSame([], $rejected['admission']['callbacks']); + if ($enabled && ! $withoutParent) { + $carrier = json_decode($admitted['carrier'], true, flags: JSON_THROW_ON_ERROR)['carrier']; + self::assertSame('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', $carrier['traceparent']); + self::assertSame('approved=origin', $carrier['baggage']); + } else { + self::assertNull($admitted['carrier']); + } + foreach ($workers as $workerResult) { + self::assertCount($enabled ? 1 : 0, $workerResult['execution_spans']); + if ($enabled) { + $span = $workerResult['execution_spans'][0]; + self::assertSame($withoutParent ? '0000000000000000' : '00f067aa0ba902b7', $span['parent_id']); + self::assertNotSame($admitted['execution_spans'][0]['span_id'], $span['parent_id']); + } + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_values( + array_filter($records, static fn (array $record): bool => $record['message'] === 'admission callback') + ); + self::assertCount(1, $callbacks); + self::assertSame('application-request', $callbacks[0]['context']['request_id']); + self::assertStringNotContainsString( + 'durable.command.outcome', + json_encode($finished['admission']['history_payloads'], JSON_THROW_ON_ERROR) + ); + self::assertCount(3, array_unique(array_column($workers, 'pid'))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/CancellationExecutionTracingTest.php b/tests/Unit/V2/CancellationExecutionTracingTest.php new file mode 100644 index 000000000..dfbfb65db --- /dev/null +++ b/tests/Unit/V2/CancellationExecutionTracingTest.php @@ -0,0 +1,259 @@ + [$command, true, true]; + yield $command . ', disabled' => [$command, false, false]; + } + yield 'request, legacy admission' => ['request', true, false]; + } + + #[DataProvider('modes')] + public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes( + string $command, + bool $enabled, + bool $admissionEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-cancellation-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode, bool $duplicate = false) use ( + $directory, + $command, + $enabled, + $admissionEnabled, + ): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_CANCELLATION' => $command, + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $enabled) ? 'true' : 'false', + 'DW_TEST_TRACING_CANCEL_DUPLICATE' => $duplicate ? 'true' : 'false', + 'DW_TEST_TRACING_PROGRESS_FILE' => $directory . '/progress.jsonl', + 'DW_TEST_TRACING_SCHEMA_PREPARED' => $mode === 'prepare-http' ? 'false' : 'true', + ]); + $process->setTimeout($mode === 'prepare-http' ? 45 : 15); + try { + $process->mustRun(); + } catch (\Throwable $error) { + $progress = is_file($directory . '/progress.jsonl') + ? substr((string) file_get_contents($directory . '/progress.jsonl'), -2048) : ''; + throw new \RuntimeException( + 'Tracing phase ' . $mode . ' failed; stages: ' . $progress, + previous: $error + ); + } + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + if ($mode === 'prepare-http') { + self::assertSame([ + 'workflow_runs' => 0, + 'commands' => 0, + 'history_events' => 0, + 'tasks' => 0, + 'jobs' => 0, + ], $result); + + return $result; + } + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $worker('prepare-http'); + $admitted = $worker('admit'); + self::assertSame($admissionEnabled, $admitted['carrier'] !== null); + $waiting = $worker('work'); + self::assertSame('waiting', $waiting['status']); + self::assertSame(0, $waiting['jobs']); + self::assertSame([], $waiting['cancellation']['activities']); + $requested = $worker('cancel'); + self::assertTrue($requested['cancellation']['result']['accepted']); + if ($command === 'request') { + self::assertSame( + $enabled ? array_fill( + 0, + 1 + count($requested['spans']) - count($requested['execution_spans']), + 0 + ) : [], + $requested['cancellation']['capture_transaction_levels'] + ); + self::assertSame($enabled, $requested['cancellation']['request_carrier'] !== null); + if ($enabled) { + self::assertSame('00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', json_decode( + $requested['cancellation']['request_carrier'], + true, + flags: JSON_THROW_ON_ERROR + )['carrier']['traceparent']); + } + $duplicate = $worker('cancel', true); + self::assertSame($requested['cancellation']['result'], $duplicate['cancellation']['result']); + self::assertSame($requested['cancellation']['deadline'], $duplicate['cancellation']['deadline']); + self::assertSame( + $requested['cancellation']['request_carrier'], + $duplicate['cancellation']['request_carrier'] + ); + self::assertSame( + $requested['cancellation']['history_hashes'], + $duplicate['cancellation']['history_hashes'] + ); + } + $workers = [$waiting]; + for ($remaining = 6; $remaining > 0; --$remaining) { + if ($worker('inspect')['jobs'] === 0) { + break; + } + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame($command === 'terminate' ? 'terminated' : 'cancelled', $finished['status'], json_encode([ + 'history_types' => $finished['cancellation']['history_types'], + 'tasks' => $finished['cancellation']['tasks'], + 'failed_jobs' => $finished['failed_jobs'], + ], JSON_THROW_ON_ERROR)); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $requested[$key]); + self::assertSame($admitted[$key], $finished[$key]); + } + foreach ([$waiting, $requested] as $prefix) { + self::assertSame($prefix['cancellation']['history_hashes'], array_slice( + $finished['cancellation']['history_hashes'], + 0, + count($prefix['cancellation']['history_hashes']) + )); + } + $originalCommands = array_column($waiting['cancellation']['commands'], null, 'id'); + $commands = array_column($finished['cancellation']['commands'], null, 'id'); + self::assertSame($originalCommands, array_intersect_key($commands, $originalCommands)); + self::assertCount(count($originalCommands) + 1, $commands); + self::assertSame( + $command === 'request' ? 'request_cancellation' : $command, + $commands[$requested['cancellation']['result']['command_id']]['command_type'] + ); + $cooperative = $command === 'request'; + self::assertSame( + $requested['cancellation']['request_carrier'], + $finished['cancellation']['request_carrier'] + ); + $terminal = end($finished['cancellation']['history_payloads']); + self::assertSame($cooperative ? [ + 'cleanup' => [9007199254740993, null, '雪'], + ] : [], $finished['cancellation']['memo'], json_encode([ + 'cleanup_deadline' => $finished['cancellation']['deadline'], + 'terminal' => is_array($terminal) ? array_intersect_key($terminal, [ + 'reason' => true, + 'cancellation_cleanup' => true, + ]) : null, + ], JSON_THROW_ON_ERROR)); + self::assertCount($cooperative ? 1 : 0, $finished['cancellation']['activities']); + if ($cooperative) { + self::assertSame('completed', $finished['cancellation']['activities'][0]['status']); + self::assertSame(1, $finished['cancellation']['activities'][0]['attempt_count']); + foreach ([ + 'CooperativeCancellationRequested', + 'CooperativeCancellationDelivered', + 'ActivityCompleted', + 'MemoUpserted', + ] as $type) { + self::assertSame(1, array_count_values($finished['cancellation']['history_types'])[$type] ?? 0); + } + } + self::assertNotContains('WorkflowCompleted', $finished['cancellation']['history_types']); + foreach ($finished['cancellation']['tasks'] as $task) { + self::assertSame(1, $task['attempt_count']); + } + $spans = array_merge(...array_column($workers, 'execution_spans')); + self::assertCount($enabled ? count($workers) : 0, $spans); + $taskIds = array_column($finished['cancellation']['tasks'], 'id'); + foreach ($spans as $span) { + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + self::assertContains($span['fields']['task_id'], $taskIds); + } + $linked = array_values(array_filter($spans, static fn (array $span): bool => $span['links'] !== [])); + self::assertCount($enabled && $cooperative ? 1 : 0, $linked); + if ($linked !== []) { + self::assertSame('durable.workflow.task', $linked[0]['name']); + self::assertCount(1, $linked[0]['links']); + self::assertSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $linked[0]['links'][0]['trace_id']); + self::assertSame('bbbbbbbbbbbbbbbb', $linked[0]['links'][0]['span_id']); + self::assertSame('request_cancellation', $linked[0]['links'][0]['fields']['command_type']); + self::assertSame( + $requested['cancellation']['result']['command_id'], + $linked[0]['links'][0]['fields']['workflow_command_id'] + ); + } + self::assertCount($enabled && $cooperative ? 1 : 0, array_filter( + $spans, + static fn (array $span): bool => $span['name'] === 'durable.activity.attempt' + )); + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + self::assertCount($cooperative ? 1 : 0, array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'callback' + )); + foreach ($records as $record) { + if ($record['message'] === 'worker outside' || ! $enabled) { + self::assertArrayNotHasKey('workflow_run_id', $record['context']); + self::assertArrayNotHasKey('trace_id', $record['context']); + } elseif (in_array($record['message'], ['cancellation cleanup', 'callback'], true)) { + self::assertSame($admitted['run_id'], $record['context']['workflow_run_id']); + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $record['context']['trace_id']); + } else { + self::assertNotSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $record['context']['trace_id']); + } + self::assertContains($record['context']['task_id'], $taskIds); + } + if ($record['message'] === 'cancellation cleanup') { + self::assertSame( + $admissionEnabled && $enabled ? 'origin' : null, + $record['context']['approved_baggage'] + ); + } + } + self::assertStringNotContainsString('observability_context', json_encode( + $finished['cancellation']['history_payloads'], + JSON_THROW_ON_ERROR + )); + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/ChildRetryExecutionTracingTest.php b/tests/Unit/V2/ChildRetryExecutionTracingTest.php new file mode 100644 index 000000000..97960b219 --- /dev/null +++ b/tests/Unit/V2/ChildRetryExecutionTracingTest.php @@ -0,0 +1,193 @@ + [$failure, true, true]; + yield $failure . ', disabled' => [$failure, false, false]; + yield $failure . ', legacy admission' => [$failure, false, true]; + yield $failure . ', disabled workers' => [$failure, true, false]; + } + } + + #[DataProvider('modes')] + public function testColdChildRetryKeepsItsAdmissionContextAndOriginalDurablePolicy( + string $failure, + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-child-retry-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode, bool $fail = false) use ( + $directory, + $admissionEnabled, + $workerEnabled, + ): array { + $autoloads = array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders()) + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...$autoloads, + ], env: [ + 'DW_TEST_TRACING_CHILD_RETRY' => 'true', + 'DW_TEST_TRACING_FAIL_CHILD' => $fail ? 'true' : 'false', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame($admissionEnabled, $admitted['carrier'] !== null); + self::assertCount(2, $admitted['runs']); + $originalChild = $admitted['runs'][1]; + $failed = $worker($failure, true); + self::assertCount(3, $failed['runs']); + $retry = $failed['runs'][2]; + self::assertSame('failed', $failed['runs'][1]['status']); + self::assertSame('pending', $retry['status']); + self::assertSame($originalChild['instance_id'], $retry['instance_id']); + self::assertSame(2, $retry['number']); + self::assertSame($originalChild['execution_deadline'], $retry['execution_deadline']); + $retryTask = array_values(array_filter( + $failed['task_rows'], + static fn (array $row): bool => $row['run_id'] === $retry['id'] + ))[0]; + $zone = new DateTimeZone('UTC'); + $started = new DateTimeImmutable($retry['started_at'], $zone); + $available = new DateTimeImmutable($retryTask['available_at'], $zone); + self::assertSame($started->modify('+1 second')->format('U.u'), $available->format('U.u')); + self::assertSame($started->modify('+20 seconds')->format('U.u'), ( + new DateTimeImmutable($retry['run_deadline'], $zone) + )->format('U.u')); + // Wait for the real queue's second-resolution wakeup, without advancing stored clocks. + $wakeup = ceil((float) $available->format('U.u')); + self::assertLessThan(5, $wakeup - microtime(true)); + while (microtime(true) < $wakeup) { + usleep(10_000); + } + $workers = [$failed]; + for ($remaining = 10; $remaining > 0; --$remaining) { + $state = $worker('inspect'); + if ($state['jobs'] === 0) { + break; + } + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertSame(1, $finished['activity_count']); + self::assertSame(1, $finished['completion_count']); + self::assertSame(6, $finished['tasks']); + self::assertCount(3, $finished['runs']); + self::assertSame(['completed', 'failed', 'completed'], array_column($finished['runs'], 'status')); + self::assertCount(2, $finished['links']); + self::assertCount(1, array_filter( + $finished['task_rows'], + static fn (array $task): bool => $task['status'] === 'failed' + )); + self::assertCount(5, array_filter( + $finished['task_rows'], + static fn (array $task): bool => $task['status'] === 'completed' + )); + self::assertSame($admitted['policy'], $failed['policy']); + self::assertSame($admitted['policy'], $finished['policy']); + self::assertSame([ + 'snapshot_version' => 1, + 'max_attempts' => 2, + 'backoff_seconds' => [1], + 'non_retryable_error_types' => [], + ], $finished['policy']); + foreach ($admitted['runs'] as $index => $run) { + foreach (['id', 'instance_id', 'carrier', 'execution_deadline', 'run_deadline', 'started_at'] as $key) { + self::assertSame($run[$key], $failed['runs'][$index][$key]); + } + self::assertSame( + $run['history_hashes'], + array_slice($failed['runs'][$index]['history_hashes'], 0, count($run['history_hashes'])) + ); + } + foreach ($failed['runs'] as $index => $run) { + foreach (['id', 'instance_id', 'execution_deadline', 'run_deadline', 'started_at'] as $key) { + self::assertSame($run[$key], $finished['runs'][$index][$key]); + } + self::assertSame($run['history_hashes'], array_slice( + $finished['runs'][$index]['history_hashes'], + 0, + count($run['history_hashes']) + )); + } + self::assertSame($failed['runs'][1]['history_hashes'], $finished['runs'][1]['history_hashes']); + foreach ($finished['task_rows'] as $task) { + self::assertSame(1, $task['attempt_count']); + self::assertGreaterThanOrEqual( + new DateTimeImmutable($task['available_at'], $zone), + new DateTimeImmutable($task['leased_at'], $zone) + ); + } + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + foreach ($finished['runs'] as $run) { + self::assertSame($admitted['carrier'], $run['carrier'], $run['id'] . ' generation ' . $run['number']); + } + $spans = array_merge(...array_column($workers, 'execution_spans')); + if ($workerEnabled) { + $observed = []; + foreach ($spans as $span) { + $observed[] = $span['fields']['workflow_run_id']; + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + } + self::assertContains($retry['id'], $observed); + self::assertContains($admitted['run_id'], $observed); + if ($failure === 'work') { + $failureSpan = array_values(array_filter( + $failed['execution_spans'], + static fn (array $span): bool => $span['fields']['workflow_run_id'] === $originalChild['id'] + )); + self::assertCount(1, $failureSpan); + self::assertSame(StatusCode::STATUS_ERROR, $failureSpan[0]['status_code']); + } + } else { + self::assertSame([], $spans); + } + self::assertStringNotContainsString( + 'observability_context', + json_encode($finished['history_payloads'], JSON_THROW_ON_ERROR) + ); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/DispatchExecutionTracingTest.php b/tests/Unit/V2/DispatchExecutionTracingTest.php new file mode 100644 index 000000000..a6a4f5846 --- /dev/null +++ b/tests/Unit/V2/DispatchExecutionTracingTest.php @@ -0,0 +1,185 @@ + ['ordinary', true, true], + 'retry' => ['retry', true, true], + 'timer and backoff' => ['timer', true, true], + 'disabled' => ['ordinary', false, false], + 'legacy admission' => ['ordinary', false, true], + ]; + } + + #[DataProvider('modes')] + public function testActualQueuedDispatchLinksColdProcessingWithoutChangingTheDurableParent( + string $scenario, + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-dispatch-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $scenario, $admissionEnabled, $workerEnabled): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, + $scenario === 'retry' ? 'retry' : 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_DISPATCH' => 'true', + 'DW_TEST_TRACING_TIMINGS' => $scenario === 'timer' ? 'true' : 'false', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + $count = $scenario === 'timer' ? 6 : ($scenario === 'retry' ? 4 : 3); + $workers = []; + for ($index = 0; $index < $count; ++$index) { + if ($scenario === 'timer' && ($index === 1 || $index === 4)) { + sleep(2); + } + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame($count, $finished['tasks']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertSame(1, $finished['dispatch']['activity_completions']); + self::assertCount($count, array_unique(array_column($workers, 'pid'))); + foreach ([$admitted, ...$workers] as $phase) { + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $phase[$field]); + self::assertSame($admitted[$field], $finished[$field]); + } + self::assertSame($phase['dispatch']['history_hashes'], array_slice( + $finished['dispatch']['history_hashes'], + 0, + count($phase['dispatch']['history_hashes']) + )); + } + $allSpans = array_merge(...array_column([$admitted, ...$workers], 'spans')); + $dispatches = array_values(array_filter( + $allSpans, + static fn (array $span): bool => $span['name'] === 'durable.task.dispatch' + )); + self::assertCount(($admissionEnabled ? 1 : 0) + ($workerEnabled ? $count - 1 : 0), $dispatches); + $dispatchByTask = []; + foreach ($dispatches as $span) { + self::assertSame(SpanKind::KIND_PRODUCER, $span['kind']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + self::assertSame('0', $span['fields']['namespace']); + self::assertSame('returned', $span['fields']['durable.dispatch.outcome']); + $dispatchByTask[$span['fields']['task_id']] = $span; + } + foreach ([$admitted, ...$workers] as $index => $phase) { + $enabled = $index === 0 ? $admissionEnabled : $workerEnabled; + $producers = array_values(array_filter( + $phase['spans'], + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_PRODUCER + )); + self::assertCount(2 * count($producers), $phase['dispatch']['boundaries']); + foreach ($phase['dispatch']['boundaries'] as $boundary) { + self::assertSame(0, $boundary['transaction_level']); + } + foreach ($phase['dispatch']['queued'] as $queued) { + if (! $enabled) { + self::assertNull($queued['trace']); + continue; + } + $carrier = $queued['trace']; + self::assertSame('durable-workflow.queue-trace', $carrier['schema']); + self::assertSame(1, $carrier['version']); + self::assertSame($queued['task_id'], $carrier['task_id']); + self::assertLessThanOrEqual(4096, strlen(json_encode($carrier, JSON_THROW_ON_ERROR))); + self::assertArrayNotHasKey('baggage', $carrier['context']['carrier']); + $producer = $dispatchByTask[$queued['task_id']]; + self::assertSame( + '00-' . $producer['trace_id'] . '-' . $producer['span_id'] . '-01', + $carrier['context']['carrier']['traceparent'] + ); + } + if ($producers !== []) { + $parent = array_values(array_filter($phase['spans'], static fn (array $span): bool => + $span['kind'] === SpanKind::KIND_CONSUMER || $span['name'] === 'durable.workflow.admission')); + self::assertCount(1, $parent); + foreach ($producers as $producer) { + self::assertSame($parent[0]['span_id'], $producer['parent_id']); + } + } + } + foreach ($workers as $index => $phase) { + $consumers = array_values(array_filter( + $phase['spans'], + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_CONSUMER + )); + self::assertCount($workerEnabled ? 1 : 0, $consumers); + if (! $workerEnabled) { + continue; + } + $span = $consumers[0]; + self::assertSame($admissionEnabled ? '00f067aa0ba902b7' : '0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + $delivered = $phase['dispatch']['delivered']; + self::assertCount(1, $delivered); + self::assertSame($span['fields']['task_id'], $delivered[0]['task_id']); + $tracedDispatch = $index === 0 ? $admissionEnabled : $workerEnabled; + $queueLinks = array_values(array_filter( + $span['links'], + static fn (array $link): bool => ($link['fields']['durable.link.type'] ?? null) === 'queue_dispatch' + )); + foreach ($span['links'] as $link) { + self::assertContains( + $link['fields']['durable.link.type'] ?? null, + ['queue_dispatch', 'task_outcome'] + ); + } + self::assertCount($tracedDispatch ? 1 : 0, $queueLinks); + if ($tracedDispatch) { + $producer = $dispatchByTask[$span['fields']['task_id']]; + self::assertSame($producer['trace_id'], $queueLinks[0]['trace_id']); + self::assertSame($producer['span_id'], $queueLinks[0]['span_id']); + self::assertSame($span['fields']['task_id'], $queueLinks[0]['fields']['task_id']); + } + } + $history = json_encode($finished['dispatch']['history_payloads'], JSON_THROW_ON_ERROR); + self::assertStringNotContainsString('dispatchObservabilityContext', $history); + self::assertStringNotContainsString('durable-workflow.queue-trace', $history); + self::assertStringNotContainsString('must-not-propagate', json_encode($allSpans, JSON_THROW_ON_ERROR)); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/EmbeddedContinuationTest.php b/tests/Unit/V2/EmbeddedContinuationTest.php index cb9411b75..e84f667f0 100644 --- a/tests/Unit/V2/EmbeddedContinuationTest.php +++ b/tests/Unit/V2/EmbeddedContinuationTest.php @@ -13,6 +13,12 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; use Mockery; +use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\API\Trace\Span; +use OpenTelemetry\Context\Context; +use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; +use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; +use OpenTelemetry\SDK\Trace\TracerProvider; use Orchestra\Testbench\TestCase; use PDO; use PHPUnit\Framework\Attributes\DataProvider; @@ -42,6 +48,7 @@ use Workflow\V2\Models\WorkflowTask; use Workflow\V2\StartOptions; use Workflow\V2\Support\DefaultServiceControlPlane; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ServiceOperationOptions; use Workflow\V2\Support\ServiceOperationResult; use Workflow\V2\Support\TaskRepairPolicy; @@ -86,6 +93,172 @@ protected function tearDown(): void } } + public static function tracingProfiles(): array + { + return [ + 'always traced' => [[true, true, true], 3], + 'off on off' => [[false, true, false], 3], + 'on off off' => [[true, false, false], 2], + 'off off on' => [[false, false, true], 2], + ]; + } + + #[DataProvider('tracingProfiles')] + public function testTracingUsesFreshTaskContextsAcrossRetainedFrameToggles(array $tracing, int $entries): void + { + $exporter = new InMemoryExporter(); + $provider = new TracerProvider(new SimpleSpanProcessor($exporter)); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + $caller = $provider->getTracer('application') + ->spanBuilder('queue-caller') + ->startSpan(); + $callerScope = $caller->activate(); + $callerContext = Context::getCurrent(); + TracingContinuationWorkflow::$observations = []; + TracingContinuationWorkflow::$entries = 0; + config() + ->set('workflows.v2.live_execution', [ + 'workflows' => [TracingContinuationWorkflow::class], + 'build' => 'tracing-continuation-build', + ]); + config() + ->set('workflows.tracing.enabled', $tracing[0]); + try { + $stub = WorkflowStub::make(TracingContinuationWorkflow::class, 'tracing-toggle', 'continuation-test'); + $stub->start(new StartOptions(executionTimeoutSeconds: 120, runTimeoutSeconds: 90)); + $run = WorkflowRun::query()->findOrFail($stub->runId()); + $deadlines = [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')]; + foreach ($tracing as $step => $enabled) { + config()->set('workflows.tracing.enabled', $enabled); + $ready = WorkflowTask::query()->where('workflow_run_id', $run->id) + ->where('task_type', 'workflow') + ->where('status', 'ready') + ->get(); + $this->assertCount(1, $ready, json_encode([ + 'step' => $step, + 'tasks' => $run->tasks() + ->get() + ->map->only(['task_type', 'status', 'last_error'])->all(), + 'failures' => $run->failures() + ->get() + ->toArray(), + ], JSON_THROW_ON_ERROR)); + $task = $ready->sole(); + $before = count(TracingContinuationWorkflow::$observations); + $this->workOnce(); + $this->assertSame($callerContext, Context::getCurrent()); + $this->assertSame([], ExecutionTracing::current()); + foreach (array_slice(TracingContinuationWorkflow::$observations, $before) as $observation) { + if ($enabled) { + $this->assertSame($task->id, $observation['fields']['task_id']); + $this->assertSame($run->id, $observation['fields']['workflow_run_id']); + $this->assertSame('continuation-test', $observation['fields']['namespace']); + $this->assertSame($observation['fields']['span_id'], $observation['parent']); + $this->assertNotSame($caller->getContext()->getSpanId(), $observation['parent']); + } else { + $this->assertSame([], $observation['fields']); + } + } + if ($enabled) { + $this->assertSame(0, app(WorkflowContinuationCache::class)->snapshot()['entries']); + } + if ($step < 2) { + if ($step === 1) { + Carbon::setTestNow(now()->addSeconds(2)); + } + $this->workOnce(); // actual activity or timer delivery + $this->assertSame($callerContext, Context::getCurrent()); + } + } + $this->assertSame('completed', $stub->refresh()->status()); + $this->assertSame($entries, TracingContinuationWorkflow::$entries); + $this->assertSame(1, ContinuationActivity::$calls); + $fresh = $run->fresh(); + $this->assertSame( + $deadlines, + [$fresh->getRawOriginal('execution_deadline_at'), $fresh->getRawOriginal('run_deadline_at')] + ); + $this->assertSame(0, app(WorkflowContinuationCache::class)->snapshot()['entries']); + $spans = array_filter( + $exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.workflow.task' + ); + $this->assertCount(count(array_filter($tracing)), $spans); + $spanIds = array_map(static fn ($span): string => $span->getContext()->getSpanId(), $spans); + $this->assertSameSize($spanIds, array_unique($spanIds)); + } finally { + $callerScope->detach(); + $caller->end(); + $configuration->detach(); + $provider->shutdown(); + } + } + + public function testTracingEnabledAtCommitCannotPublishThePreviouslyUntracedFrame(): void + { + $this->enable(); + $toggle = true; + WorkflowTask::updated(static function (WorkflowTask $task) use (&$toggle): void { + if ($toggle && $task->task_type->value === 'workflow' && $task->status->value === 'completed') { + $toggle = false; + DB::afterCommit(static fn () => config()->set('workflows.tracing.enabled', true)); + } + }); + $stub = WorkflowStub::make(ContinuationWorkflow::class, 'commit-toggle', 'continuation-test'); + $stub->start(); + $this->workOnce(); + $this->assertFalse($toggle); + $this->assertTrue(ExecutionTracing::enabled()); + $this->assertSame(0, app(WorkflowContinuationCache::class)->snapshot()['entries']); + config() + ->set('workflows.tracing.enabled', false); + $this->workOnce(); + $this->workOnce(); + Carbon::setTestNow(now()->addSeconds(2)); + $this->workOnce(); + $this->workOnce(); + $this->assertSame('completed', $stub->refresh()->status()); + $this->assertSame(2, ContinuationWorkflow::$entries); + $this->assertSame(1, ContinuationActivity::$calls); + } + + public function testTracingEnabledAfterSessionCreationStillStartsCold(): void + { + $this->enable(); + $armed = false; + $toggle = true; + WorkflowTask::updated(static function (WorkflowTask $task) use (&$armed, &$toggle): void { + if (! $armed && $task->status->value === 'leased') { + $armed = true; + DB::afterCommit(static function () use (&$toggle): void { + WorkflowRun::retrieved(static function () use (&$toggle): void { + if ($toggle) { + $toggle = false; + config() + ->set('workflows.tracing.enabled', true); + } + }); + }); + } + }); + $stub = WorkflowStub::make(ContinuationWorkflow::class, 'entry-toggle', 'continuation-test'); + $stub->start(); + $this->workOnce(); + $this->assertFalse($toggle); + $this->assertTrue(ExecutionTracing::enabled()); + $this->assertSame(0, app(WorkflowContinuationCache::class)->snapshot()['entries']); + config() + ->set('workflows.tracing.enabled', false); + $this->workOnce(); + $this->workOnce(); + Carbon::setTestNow(now()->addSeconds(2)); + $this->workOnce(); + $this->workOnce(); + $this->assertSame('completed', $stub->refresh()->status()); + $this->assertSame(2, ContinuationWorkflow::$entries); + $this->assertSame(1, ContinuationActivity::$calls); + } + public static function profiles(): array { return [ @@ -1202,6 +1375,34 @@ private function registerNexusService(int &$starts): WorkflowRun } } +#[Type('tracing-continuation-workflow')] +final class TracingContinuationWorkflow extends Workflow +{ + public static array $observations = []; + + public static int $entries = 0; + + public function handle(): array + { + ++self::$entries; + self::observe(); + $value = Workflow::activity(ContinuationActivity::class); + self::observe(); + Workflow::timer(1); + self::observe(); + return $value; + } + + private static function observe(): void + { + $fields = ExecutionTracing::current(); + self::$observations[] = [ + 'fields' => $fields, + 'parent' => $fields === [] ? null : Span::getCurrent()->getContext()->getSpanId(), + ]; + } +} + #[Type('continuation-workflow')] final class ContinuationWorkflow extends Workflow { diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php new file mode 100644 index 000000000..3199723bb --- /dev/null +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -0,0 +1,1042 @@ +exporter = new InMemoryExporter(); + $this->provider = new TracerProvider(new SimpleSpanProcessor($this->exporter)); + $this->configuration = Configurator::create()->withTracerProvider($this->provider)->activate(); + $this->initial = Context::getCurrent(); + } + + protected function tearDown(): void + { + self::assertSame($this->initial, Context::getCurrent()); + self::assertSame([], ExecutionTracing::current()); + $this->configuration->detach(); + $this->provider->shutdown(); + } + + public function testRealProviderAndLoggingKeepDurableRequestAndTraceIdentitiesSeparate(): void + { + $handler = new TestHandler(); + $logger = new LaravelLogger(new Logger('application', [$handler])); + (new LogContext())($logger); + $foreign = Context::createKey('unrelated-worker-state'); + $ambient = Context::getCurrent()->with($foreign, 'private')->activate(); + try { + $result = ExecutionTracing::activity(self::fields(), self::carrier(), function () use ( + $logger, + $foreign + ): int { + self::assertSame($this->provider, Globals::tracerProvider()); + self::assertNull(Context::getCurrent()->get($foreign)); + $fields = ExecutionTracing::current(); + self::assertSame(self::TRACE_ID, $fields['trace_id']); + self::assertNotSame(self::PARENT_ID, $fields['span_id']); + self::assertSame('0', $fields['namespace']); + $logger->info('activity executing', [ + 'request_id' => 'application-request', + ]); + $client = Globals::tracerProvider()->getTracer('application')->spanBuilder('outbound')->startSpan(); + $clientScope = $client->activate(); + try { + $logger->info('outbound executing'); + self::assertSame($fields['workflow_run_id'], ExecutionTracing::current()['workflow_run_id']); + self::assertSame($client->getContext()->getSpanId(), ExecutionTracing::current()['span_id']); + } finally { + $clientScope->detach(); + } + self::assertSame($fields, ExecutionTracing::current()); + $client->end(); + + return 7; + }, enabled: true); + self::assertSame(7, $result); + self::assertSame('private', Context::getCurrent()->get($foreign)); + } finally { + $ambient->detach(); + } + $logger->info('after activity', [ + 'request_id' => 'outside', + ]); + $records = $handler->getRecords(); + self::assertSame('run-1', $records[0]['context']['workflow_run_id']); + self::assertSame('application-request', $records[0]['context']['request_id']); + self::assertArrayNotHasKey('arguments', $records[0]['context']); + self::assertSame([ + 'request_id' => 'outside', + ], $records[2]['context']); + $spans = $this->exporter->getSpans(); + self::assertCount(2, $spans); + self::assertSame($spans[0]->getContext()->getSpanId(), $records[1]['context']['span_id']); + self::assertSame($spans[1]->getContext()->getSpanId(), $spans[0]->getParentContext()->getSpanId()); + self::assertSame(self::PARENT_ID, $spans[1]->getParentContext()->getSpanId()); + self::assertSame('origin-request', $spans[1]->getAttributes()->get('request_id')); + self::assertNull($spans[1]->getAttributes()->get('arguments')); + } + + public static function diagnosticCarriers(): iterable + { + foreach (TraceCarrierTest::invalidCarriers() as $name => [$carrier]) { + yield $name => [$carrier, $carrier === null ? null : 'discarded', false]; + } + yield 'valid' => [self::carrier(), null, true]; + $filtered = self::carrier(); + $filtered['carrier']['baggage'] = 'private=secret'; + yield 'filtered' => [$filtered, 'filtered', true]; + } + + #[DataProvider('diagnosticCarriers')] + public function testCarrierDiagnosticsAreBoundedAndDoNotTurnSuccessfulWorkIntoErrors( + mixed $carrier, + ?string $diagnostic, + bool $parentValid, + ): void { + self::assertSame(7, ExecutionTracing::activity( + self::fields(), + $carrier, + static fn (): int => 7, + enabled: true + )); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + $span = $spans[0]; + self::assertSame($diagnostic, $span->getAttributes()->get('durable.trace_context.disposition')); + self::assertSame($parentValid, $span->getParentContext()->isValid()); + self::assertNotSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertSame([], $span->getEvents()); + $attributes = json_encode($span->getAttributes()->toArray(), JSON_THROW_ON_ERROR); + foreach (['secret', 'private', 'traceparent', 'baggage', 'must not be captured'] as $forbidden) { + self::assertStringNotContainsString($forbidden, $attributes); + } + } + + public function testDiscardedQueueLinkPreservesTheRunParentAndOriginalBusinessResult(): void + { + self::assertSame(7, ExecutionTracing::activity( + self::fields(), + self::carrier(), + static fn (): int => 7, + enabled: true, + dispatchContext: 'private-invalid-queue-context' + )); + $span = $this->exporter->getSpans()[0]; + self::assertSame('discarded', $span->getAttributes()->get('durable.queue_context.disposition')); + self::assertSame(self::PARENT_ID, $span->getParentContext()->getSpanId()); + self::assertSame([], $span->getLinks()); + self::assertSame([], $span->getEvents()); + self::assertNotSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertStringNotContainsString('private-invalid-queue-context', json_encode( + $span->getAttributes() + ->toArray(), + JSON_THROW_ON_ERROR + )); + } + + public function testAttemptsHaveDistinctSpansAndNestedScopesRestoreTheirCaller(): void + { + ExecutionTracing::activity(self::fields(), self::carrier(), static function (): void { + $outer = ExecutionTracing::current(); + ExecutionTracing::activity(self::fields('run-2', 'attempt-2'), self::carrier(), static function (): void { + self::assertSame('run-2', ExecutionTracing::current()['workflow_run_id']); + }, enabled: true); + self::assertSame($outer, ExecutionTracing::current()); + }, enabled: true); + $spans = $this->exporter->getSpans(); + self::assertCount(2, $spans); + self::assertNotSame($spans[0]->getContext()->getSpanId(), $spans[1]->getContext()->getSpanId()); + self::assertSame('activity-1', $spans[0]->getAttributes()->get('activity_id')); + self::assertSame('activity-1', $spans[1]->getAttributes()->get('activity_id')); + self::assertSame('attempt-2', $spans[0]->getAttributes()->get('activity_attempt_id')); + self::assertSame('attempt-1', $spans[1]->getAttributes()->get('activity_attempt_id')); + } + + public function testConcurrentFibersKeepIndependentExecutionContexts(): void + { + $handler = new TestHandler(); + $logger = new LaravelLogger(new Logger('fibers', [$handler])); + (new LogContext())($logger); + $operation = function (string $run) use ($logger): void { + // Configuration is execution-local too; each Fiber owns its provider scope. + $configured = Configurator::create()->withTracerProvider($this->provider) + ->storeInContext(Context::getRoot())->activate(); + try { + ExecutionTracing::activity(self::fields($run), self::carrier(), static function () use ( + $run, + $logger + ): void { + self::assertSame($run, ExecutionTracing::current()['workflow_run_id']); + $logger->info('before suspension'); + Fiber::suspend(); + self::assertSame($run, ExecutionTracing::current()['workflow_run_id']); + $logger->info('after resumption'); + }, enabled: true); + self::assertSame([], ExecutionTracing::current()); + } finally { + $configured->detach(); + } + }; + $first = new Fiber(static fn () => $operation('run-a')); + $second = new Fiber(static fn () => $operation('run-b')); + $first->start(); + self::assertSame([], ExecutionTracing::current()); + $second->start(); + self::assertSame([], ExecutionTracing::current()); + $first->resume(); + $second->resume(); + self::assertCount(2, $this->exporter->getSpans()); + self::assertSame(['run-a', 'run-b', 'run-a', 'run-b'], array_map( + static fn ($record) => $record['context']['workflow_run_id'], + $handler->getRecords(), + )); + } + + public function testLegacyAndMalformedCarriersStartSeparateSegmentsWithoutWorkerParentage(): void + { + foreach ([ + null, [ + 'schema' => 'invalid', + ]] as $carrier) { + ExecutionTracing::activity(self::fields(), $carrier, static function (): void { + self::assertNotSame(self::TRACE_ID, Span::getCurrent()->getContext()->getTraceId()); + }, enabled: true); + } + foreach ($this->exporter->getSpans() as $span) { + self::assertFalse($span->getParentContext()->isValid()); + } + } + + public function testWorkflowFibersInheritTheirCreatorWithoutReplacingApplicationStorage(): void + { + $storage = Context::storage(); + $executions = []; + $observed = []; + foreach (['run-a', 'run-b'] as $run) { + ExecutionTracing::activity(self::fields($run), self::carrier(), function () use ( + $run, + &$executions, + &$observed + ): void { + $parent = ExecutionTracing::current(); + $executions[] = WorkflowExecution::startCallback(function () use ($run, $parent, &$observed): int { + self::assertSame($this->provider, Globals::tracerProvider()); + self::assertSame($parent, ExecutionTracing::current()); + $observed[] = ExecutionTracing::current()['workflow_run_id']; + Fiber::suspend(); + self::assertSame($parent, ExecutionTracing::current()); + $observed[] = ExecutionTracing::current()['workflow_run_id']; + + return $run === 'run-a' ? 7 : 9; + }); + self::assertSame($parent, ExecutionTracing::current()); + }, enabled: true); + self::assertSame($this->initial, Context::getCurrent()); + } + foreach ($executions as $execution) { + $execution->send(null); + self::assertSame($this->initial, Context::getCurrent()); + } + self::assertSame([7, 9], array_map(static fn ($execution) => $execution->getReturn(), $executions)); + self::assertSame(['run-a', 'run-b', 'run-a', 'run-b'], $observed); + self::assertSame($storage, Context::storage()); + } + + public function testWorkflowFiberFailureAndDiscardUnwindTheBoundScope(): void + { + $failure = new RuntimeException('application failure'); + $unwound = []; + $first = null; + $second = null; + ExecutionTracing::activity(self::fields(), self::carrier(), static function () use ( + &$first, + &$second, + &$unwound + ): void { + $callback = static function (string $id) use (&$unwound): void { + try { + Fiber::suspend(); + } finally { + $unwound[$id] = ExecutionTracing::current(); + } + }; + $first = WorkflowExecution::startCallback($callback, ['failure']); + $second = WorkflowExecution::startCallback($callback, ['discard']); + }, enabled: true); + try { + $first->throw($failure); + self::fail('The workflow exception must survive context cleanup.'); + } catch (RuntimeException $observed) { + self::assertSame($failure, $observed); + } + self::assertSame($this->initial, Context::getCurrent()); + unset($second); + self::assertSame($this->initial, Context::getCurrent()); + self::assertSame(['failure', 'discard'], array_keys($unwound)); + foreach ($unwound as $fields) { + self::assertSame('run-1', $fields['workflow_run_id']); + self::assertSame(self::TRACE_ID, $fields['trace_id']); + } + } + + public function testUnboundFiberDoesNotReadTheMainFibersDurableContext(): void + { + set_error_handler(static function (int $severity, string $message): never { + throw new RuntimeException($message); + }); + try { + ExecutionTracing::activity(self::fields(), self::carrier(), static function (): void { + $fields = ExecutionTracing::current(); + $fiber = new Fiber(static function (): void { + self::assertSame([], ExecutionTracing::current()); + Fiber::suspend(); + self::assertSame([], ExecutionTracing::current()); + }); + $fiber->start(); + self::assertSame($fields, ExecutionTracing::current()); + $fiber->resume(); + self::assertSame($fields, ExecutionTracing::current()); + }, enabled: true); + } finally { + restore_error_handler(); + } + } + + public function testDisabledInstrumentationLeavesTheApplicationContextUntouched(): void + { + $calls = 0; + $result = ExecutionTracing::activity(self::fields(), self::carrier(), function () use (&$calls): int { + ++$calls; + self::assertSame($this->initial, Context::getCurrent()); + + return 9; + }); + self::assertSame(9, $result); + self::assertSame(1, $calls); + self::assertSame([], $this->exporter->getSpans()); + } + + public function testAdmissionPreservesTheApplicationParentProviderAndOriginalException(): void + { + $instance = new WorkflowInstance(); + $instance->setRawAttributes([ + 'id' => 'instance-1', + 'workflow_type' => 'test.workflow', + 'namespace' => '0', + ], true); + $foreign = Context::createKey('application-admission-state'); + $parent = TraceContextPropagator::getInstance()->extract( + self::carrier()['carrier'], + context: Context::getCurrent() + )->with($foreign, 'application-value'); + $scope = $parent->activate(); + $error = new RuntimeException('private-original-admission-error'); + $calls = 0; + try { + try { + ExecutionTracing::admission($instance, function () use ($foreign, $error, &$calls): never { + ++$calls; + self::assertSame($this->provider, Globals::tracerProvider()); + self::assertSame('application-value', Context::getCurrent()->get($foreign)); + self::assertSame('instance-1', ExecutionTracing::current()['workflow_instance_id']); + throw $error; + }, enabled: true); + self::fail('Admission must preserve the original exception.'); + } catch (RuntimeException $observed) { + self::assertSame($error, $observed); + } + self::assertSame($parent, Context::getCurrent()); + self::assertSame([], ExecutionTracing::current()); + } finally { + $scope->detach(); + } + self::assertSame(1, $calls); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + self::assertSame(SpanKind::KIND_INTERNAL, $spans[0]->getKind()); + self::assertSame(self::TRACE_ID, $spans[0]->getContext()->getTraceId()); + self::assertSame(self::PARENT_ID, $spans[0]->getParentContext()->getSpanId()); + self::assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[0]->getAttributes()->get('error.type')); + self::assertSame([], $spans[0]->getEvents()); + self::assertStringNotContainsString('private-original', $spans[0]->getStatus()->getDescription()); + } + + public function testAdmissionOptionalIdentityAndReceiptReadsCannotReplaceTheResult(): void + { + $instance = new class() extends WorkflowInstance { + public int $reads = 0; + + public function getRawOriginal($key = null, $default = null): mixed + { + ++$this->reads; + throw new RuntimeException('private-identity-read'); + } + }; + $command = new class() extends WorkflowCommand { + public int $reads = 0; + + public function getAttribute($key): mixed + { + ++$this->reads; + throw new RuntimeException('private-receipt-read'); + } + }; + $receipt = StartResult::fromCommand($command); + $calls = 0; + $operation = static function () use (&$calls, $receipt): StartResult { + ++$calls; + + return $receipt; + }; + self::assertSame($receipt, ExecutionTracing::admission($instance, $operation)); + self::assertSame(0, $instance->reads); + self::assertSame(0, $command->reads); + self::assertSame([], $this->exporter->getSpans()); + self::assertSame($receipt, ExecutionTracing::admission($instance, $operation, enabled: true)); + self::assertSame(2, $calls); + self::assertSame(1, $instance->reads); + self::assertSame(1, $command->reads); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + self::assertNull($spans[0]->getAttributes()->get('workflow_instance_id')); + self::assertNull($spans[0]->getAttributes()->get('durable.command.outcome')); + self::assertSame([], $spans[0]->getEvents()); + self::assertStringNotContainsString( + 'private-', + json_encode($spans[0]->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + } + + public function testSignalWithStartAdmissionReadsOnlyTheRecordedReceipts(): void + { + $instance = new WorkflowInstance(); + $signal = new class() extends WorkflowCommand { + public function getAttribute($key): mixed + { + if (in_array($key, ['payload', 'observability_context'], true)) { + throw new RuntimeException('Business data is not admission telemetry.'); + } + + return parent::getAttribute($key); + } + }; + $signal->setRawAttributes([ + 'id' => 'signal-1', + 'workflow_instance_id' => 'instance-1', + 'workflow_run_id' => 'run-1', + 'workflow_type' => 'test.workflow', + 'command_type' => 'signal', + 'status' => 'accepted', + 'outcome' => 'signal_received', + 'payload' => 'private-command-body', + 'observability_context' => 'private-command-carrier', + ], true); + $start = clone $signal; + $start->setRawAttributes(array_replace($signal->getRawOriginal(), [ + 'id' => 'start-1', + 'command_type' => 'start', + 'outcome' => 'started_new', + ]), true); + $original = [$signal->getRawOriginal(), $start->getRawOriginal()]; + $result = SignalWithStartResult::fromCommands($signal, $start); + self::assertSame($result, ExecutionTracing::admission($instance, static fn () => $result, enabled: true)); + $span = $this->exporter->getSpans()[0]; + foreach ([ + 'workflow_command_id' => 'signal-1', + 'durable.command.type' => 'signal', + 'durable.command.status' => 'accepted', + 'durable.command.outcome' => 'signal_received', + 'durable.start.command_id' => 'start-1', + 'durable.start.status' => 'accepted', + 'durable.start.outcome' => 'started_new', + ] as $key => $value) { + self::assertSame($value, $span->getAttributes()->get($key)); + } + self::assertSame($original, [$signal->getRawOriginal(), $start->getRawOriginal()]); + self::assertStringNotContainsString( + 'private-command', + json_encode($span->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + } + + public function testOptionalPairedStartReceiptFailureCannotReplaceSignalAdmission(): void + { + $signal = self::signalCommand(null); + $signal->outcome = 'signal_received'; + $start = new class() extends WorkflowCommand { + public function getAttribute($key): mixed + { + throw new RuntimeException('private-start-receipt-error'); + } + }; + $result = SignalWithStartResult::fromCommands($signal, $start); + $calls = 0; + self::assertSame($result, ExecutionTracing::admission(new WorkflowInstance(), static function () use ( + &$calls, + $result + ) { + ++$calls; + + return $result; + }, enabled: true)); + self::assertSame(1, $calls); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + self::assertSame([], $spans[0]->getEvents()); + self::assertNotSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); + self::assertStringNotContainsString( + 'private-start-receipt', + json_encode($spans[0]->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + } + + public function testQueueDispatchUsesOnlyAnExplicitDurableParentAndPreservesTheOriginalFailure(): void + { + $task = new WorkflowTask(); + $task->setRawAttributes([ + 'workflow_run_id' => 'run-1', + 'namespace' => '0', + 'task_type' => 'workflow', + ], true); + $job = new RunWorkflowTask('next-task'); + $foreign = Context::createKey('unrelated-dispatch-state'); + $ambient = Context::getCurrent()->with($foreign, 'private')->activate(); + try { + self::assertSame(7, ExecutionTracing::dispatch($task, $job, static function () use ($foreign): int { + self::assertNull(Context::getCurrent()->get($foreign)); + self::assertSame('next-task', ExecutionTracing::current()['task_id']); + + return 7; + }, enabled: true)); + self::assertSame('private', Context::getCurrent()->get($foreign)); + self::assertFalse($this->exporter->getSpans()[0]->getParentContext()->isValid()); + $failure = new RuntimeException('private-dispatch-failure'); + ExecutionTracing::workflow(self::fields(), self::carrier(), static function () use ( + $task, + $job, + $failure + ): void { + $context = ExecutionTracing::current(); + try { + ExecutionTracing::dispatch($task, $job, static fn () => throw $failure, enabled: true); + self::fail('The original dispatch exception must survive.'); + } catch (RuntimeException $observed) { + self::assertSame($failure, $observed); + } + self::assertSame($context, ExecutionTracing::current()); + }, enabled: true); + $spans = $this->exporter->getSpans(); + self::assertCount(3, $spans); + self::assertSame($spans[2]->getContext()->getSpanId(), $spans[1]->getParentContext()->getSpanId()); + self::assertSame(StatusCode::STATUS_ERROR, $spans[1]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[1]->getAttributes()->get('error.type')); + self::assertSame([], $spans[1]->getEvents()); + self::assertNull($spans[1]->getAttributes()->get('durable.dispatch.outcome')); + self::assertStringNotContainsString('private-dispatch', $spans[1]->getStatus()->getDescription()); + } finally { + $ambient->detach(); + } + } + + public function testDispatchSetupFailureAndDisabledInstrumentationCannotEnrichOrReplaceTheOperation(): void + { + $processor = new class(new InMemoryExporter()) extends SimpleSpanProcessor { + public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentContext): void + { + throw new RuntimeException('optional-start-failure'); + } + }; + $provider = new TracerProvider($processor); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + $task = new class() extends WorkflowTask { + public int $reads = 0; + + public function getRawOriginal($key = null, $default = null): mixed + { + ++$this->reads; + throw new RuntimeException('optional-task-metadata'); + } + }; + $job = new RunWorkflowTask('next-task'); + $calls = 0; + $operation = static function () use (&$calls): array { + ++$calls; + + return [7, null, '雪']; + }; + try { + self::assertSame([7, null, '雪'], ExecutionTracing::dispatch($task, $job, $operation)); + self::assertSame(0, $task->reads); + self::assertSame([7, null, '雪'], ExecutionTracing::dispatch($task, $job, $operation, enabled: true)); + self::assertSame(1, $task->reads); + self::assertSame(2, $calls); + self::assertNull($job->dispatchObservabilityContext); + self::assertSame([], ExecutionTracing::current()); + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + + public function testAllConsumerKindsExposeTheDispatchLinkToTheSamplerWithoutReplacingTheirRunParent(): void + { + $sampler = new class() extends AlwaysOnSampler { + public array $links = []; + + public function shouldSample( + ContextInterface $parentContext, + string $traceId, + string $spanName, + int $spanKind, + AttributesInterface $attributes, + array $links + ): SamplingResult { + if ($spanKind === SpanKind::KIND_CONSUMER) { + $this->links[] = [$spanName, $links]; + } + + return parent::shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + } + }; + $provider = new TracerProvider(new SimpleSpanProcessor($this->exporter), sampler: $sampler); + $configuration = Configurator::create()->withTracerProvider($provider) + ->withPropagator(TraceContextPropagator::getInstance())->activate(); + try { + $task = new WorkflowTask(); + $task->setRawAttributes([ + 'workflow_run_id' => 'run-1', + 'namespace' => '0', + 'task_type' => 'workflow', + ], true); + $job = new RunWorkflowTask('next-task'); + self::assertSame(7, ExecutionTracing::dispatch($task, $job, static fn () => 7, enabled: true)); + self::assertNotNull($job->dispatchObservabilityContext); + $producer = $this->exporter->getSpans()[0]; + self::assertNotSame(self::TRACE_ID, $producer->getContext()->getTraceId()); + foreach (['workflow', 'activity', 'timer'] as $method) { + self::assertSame(9, ExecutionTracing::{$method}( + array_replace(self::fields(), [ + 'task_id' => 'next-task', + ]), + self::carrier(), + static fn () => 9, + enabled: true, + dispatchContext: $job->dispatchObservabilityContext + )); + } + self::assertCount(3, $sampler->links); + foreach ($sampler->links as [$name, $links]) { + self::assertContains( + $name, + ['durable.workflow.task', 'durable.activity.attempt', 'durable.timer.task'] + ); + self::assertCount(1, $links); + self::assertSame($producer->getContext()->getSpanId(), $links[0]->getSpanContext()->getSpanId()); + self::assertSame('next-task', $links[0]->getAttributes()->get('task_id')); + } + self::assertCount(4, $this->exporter->getSpans()); + foreach (array_slice($this->exporter->getSpans(), 1) as $consumer) { + self::assertSame(self::TRACE_ID, $consumer->getContext()->getTraceId()); + self::assertSame(self::PARENT_ID, $consumer->getParentContext()->getSpanId()); + } + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + + public function testOptionalApiAbsenceStillExecutesTheCallbackExactlyOnce(): void + { + $script = <<<'PHP' + require $argv[1]; + $calls = 0; + $result = \Workflow\V2\Support\ExecutionTracing::activity([], null, function () use (&$calls) { + ++$calls; + return [7, null, '雪']; + }, enabled: true); + echo json_encode([$calls, $result, \Workflow\V2\Support\ExecutionTracing::current()]); + PHP; + $process = new Process([ + PHP_BINARY, + '-n', + '-r', + $script, + __DIR__ . '/../../../src/V2/Support/ExecutionTracing.php', + ]); + $process->setTimeout(10); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertSame( + [1, [7, null, '雪'], []], + json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR) + ); + } + + public function testBusinessFailureKeepsItsIdentityAndDoesNotExportItsMessage(): void + { + $error = new RuntimeException('sensitive application detail'); + try { + ExecutionTracing::activity(self::fields(), self::carrier(), static function () use ($error): never { + throw $error; + }, enabled: true); + self::fail('The application exception must survive tracing.'); + } catch (RuntimeException $observed) { + self::assertSame($error, $observed); + } + $span = $this->exporter->getSpans()[0]; + self::assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $span->getAttributes()->get('error.type')); + self::assertSame([], $span->getEvents()); + self::assertStringNotContainsString('sensitive', $span->getStatus()->getDescription()); + } + + public function testProcessorFailureCannotReplaceTheBusinessResult(): void + { + $processor = new class(new InMemoryExporter()) extends SimpleSpanProcessor { + public int $calls = 0; + + public function onEnd(ReadableSpanInterface $span): void + { + ++$this->calls; + throw new RuntimeException('collector unavailable'); + } + }; + $provider = new TracerProvider($processor); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + try { + self::assertSame( + 7, + ExecutionTracing::activity(self::fields(), self::carrier(), static fn (): int => 7, enabled: true) + ); + self::assertSame(1, $processor->calls); + self::assertSame([], ExecutionTracing::current()); + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + + public function testSignalLinksStayOnTheProcessingSpanAndHaveABoundedPerSegmentLifetime(): void + { + $run = self::signalRun(); + $first = null; + $result = ExecutionTracing::workflow(self::fields(), self::carrier(), function () use ($run, &$first): int { + $nested = $this->provider->getTracer('application') + ->spanBuilder('application child') + ->startSpan(); + $scope = $nested->activate(); + try { + $original = ExecutionTracing::current(); + for ($index = 0; $index < 36; ++$index) { + $carrier = self::carrier(); + $carrier['carrier']['traceparent'] = '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-' + . str_pad(dechex($index + 1), 16, '0', STR_PAD_LEFT) . '-01'; + $command = self::signalCommand($carrier, 'signal-' . $index); + $first ??= $command; + ExecutionTracing::linkSignal($run, $command); + self::assertSame($original, ExecutionTracing::current()); + } + $duplicate = clone $first; + $duplicate->id = 'another-command-from-the-first-caller'; + ExecutionTracing::linkSignal($run, $duplicate); + } finally { + $scope->detach(); + $nested->end(); + } + + return 7; + }, enabled: true); + self::assertSame(7, $result); + $spans = $this->exporter->getSpans(); + self::assertCount(2, $spans); + self::assertSame([], $spans[0]->getLinks()); + self::assertCount(32, $spans[1]->getLinks()); + self::assertSame(4, $spans[1]->getAttributes()->get('durable.links.dropped')); + self::assertSame('signal-0', $spans[1]->getLinks()[0]->getAttributes()->get('workflow_command_id')); + self::assertSame(self::TRACE_ID, $spans[1]->getContext()->getTraceId()); + self::assertSame(self::PARENT_ID, $spans[1]->getParentContext()->getSpanId()); + ExecutionTracing::linkSignal($run, $first); + self::assertCount(32, $spans[1]->getLinks()); + ExecutionTracing::workflow(self::fields(), self::carrier(), static function () use ($run, $first): void { + ExecutionTracing::linkSignal($run, $first); + }, enabled: true); + $next = $this->exporter->getSpans()[2]; + self::assertCount(1, $next->getLinks()); + self::assertNull($next->getAttributes()->get('durable.links.dropped')); + } + + public function testInvalidForeignAndUnboundSignalMetadataCannotLinkOrReplaceTheBusinessResult(): void + { + $run = self::signalRun(); + $valid = self::carrier(); + $valid['carrier']['traceparent'] = '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; + $valid['carrier']['baggage'] = 'approved=caller'; + $command = self::signalCommand($valid); + $result = ExecutionTracing::workflow(self::fields(), self::carrier(), static function () use ( + $run, + $command, + $valid + ): int { + $original = ExecutionTracing::current(); + $invalid = [null, 'invalid', [ + 'schema' => 'unknown', + 'version' => 1, + 'carrier' => [], + ], + array_replace($valid, [ + 'version' => 2, + ]), + array_replace($valid, [ + 'carrier' => [ + 'traceparent' => str_repeat('a', 257), + ], + ]), + array_replace($valid, [ + 'carrier' => [ + 'traceparent' => $valid['carrier']['traceparent'], + 'baggage' => str_repeat('a', 2049), + ], + ]), + array_replace($valid, [ + 'carrier' => [ + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-0000000000000000-01', + ], + ]), + ]; + foreach ($invalid as $carrier) { + ExecutionTracing::linkSignal($run, self::signalCommand($carrier)); + } + foreach ([ + 'workflow_run_id' => 'other-run', + 'workflow_instance_id' => 'other-instance', + 'status' => 'rejected', + 'command_type' => 'update', + ] as $key => $value) { + $foreign = clone $command; + $foreign->{$key} = $value; + ExecutionTracing::linkSignal($run, $foreign); + } + $foreignRun = clone $run; + $foreignRun->namespace = 'other'; + ExecutionTracing::linkSignal($foreignRun, $command); + $unreadable = new class() extends WorkflowCommand { + public function getAttribute($key): mixed + { + if ($key === 'observability_context') { + throw new RuntimeException('private-telemetry-cast'); + } + + return parent::getAttribute($key); + } + }; + $unreadable->setRawAttributes($command->getRawOriginal()); + ExecutionTracing::linkSignal($run, $unreadable); + (new Fiber(static function () use ($run, $command): void { + ExecutionTracing::linkSignal($run, $command); + }))->start(); + self::assertSame($original, ExecutionTracing::current()); + ExecutionTracing::linkSignal($run, $command); + self::assertSame($original, ExecutionTracing::current()); + + return 9; + }, enabled: true); + self::assertSame(9, $result); + $span = $this->exporter->getSpans()[0]; + self::assertCount(1, $span->getLinks()); + self::assertSame('signal-1', $span->getLinks()[0]->getAttributes()->get('workflow_command_id')); + self::assertSame(self::TRACE_ID, $span->getContext()->getTraceId()); + self::assertSame(self::PARENT_ID, $span->getParentContext()->getSpanId()); + self::assertStringNotContainsString( + 'private-telemetry-cast', + json_encode($span->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + self::assertSame(11, ExecutionTracing::workflow(self::fields(), self::carrier(), static function () use ( + $run, + $command + ): int { + ExecutionTracing::linkSignal($run, $command); + + return 11; + }, enabled: false)); + self::assertCount(1, $this->exporter->getSpans()); + } + + public function testCancellationLinksUseOnlyTheOriginalLoadedRequestWithoutDatabaseOrContextFallback(): void + { + $run = self::signalRun(); + $run->cancellation_request_command_id = 'request-1'; + $carrier = self::carrier(); + $carrier['carrier']['traceparent'] = '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; + $command = self::signalCommand($carrier, 'request-1'); + $command->command_type = 'request_cancellation'; + $original = $command->getAttributes(); + $result = ExecutionTracing::workflow(self::fields(), self::carrier(), static function () use ( + $run, + $command + ): int { + // An unloaded relation must not query a database or invent a caller. + ExecutionTracing::linkCancellation($run); + $run->setRelation('commands', new \Illuminate\Database\Eloquent\Collection([$command])); + $run->cancellation_request_command_id = 'other-request'; + ExecutionTracing::linkCancellation($run); + $run->cancellation_request_command_id = $command->id; + $command->command_type = 'signal'; + ExecutionTracing::linkCancellation($run); + $command->command_type = 'request_cancellation'; + ExecutionTracing::linkCancellation($run); + ExecutionTracing::linkCancellation($run); + + return 9; + }, enabled: true); + self::assertSame(9, $result); + $span = $this->exporter->getSpans()[0]; + self::assertCount(1, $span->getLinks()); + self::assertSame('request_cancellation', $span->getLinks()[0]->getAttributes()->get('command_type')); + self::assertSame('request-1', $span->getLinks()[0]->getAttributes()->get('workflow_command_id')); + self::assertSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $span->getLinks()[0]->getSpanContext()->getTraceId()); + self::assertSame($original, $command->getAttributes()); + } + + public function testTimerSpanKeepsFiniteTimingsOutsideLoggingContextAndPreservesTheOriginalFailure(): void + { + $error = new RuntimeException('original-timer-failure'); + try { + ExecutionTracing::timer([ + ...self::fields(), + 'timer_id' => 'timer-1', + 'durable.task.queue_wait_seconds' => 0.250001, + 'durable.task.retry_backoff_seconds' => 7, + 'durable.timer.requested_wait_seconds' => NAN, + 'durable.timer.lateness_seconds' => -1, + 'private.payload' => 'excluded-body', + ], self::carrier(), static function () use ($error): never { + self::assertArrayNotHasKey('durable.task.queue_wait_seconds', ExecutionTracing::current()); + self::assertSame('timer-1', ExecutionTracing::current()['timer_id']); + throw $error; + }, enabled: true); + self::fail('Expected the original failure.'); + } catch (RuntimeException $caught) { + self::assertSame($error, $caught); + } + $span = $this->exporter->getSpans()[0]; + self::assertSame('durable.timer.task', $span->getName()); + self::assertTrue($span->hasEnded()); + self::assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + $attributes = $span->getAttributes() + ->toArray(); + self::assertSame(0.250001, $attributes['durable.task.queue_wait_seconds']); + self::assertSame(7, $attributes['durable.task.retry_backoff_seconds']); + self::assertArrayNotHasKey('durable.timer.requested_wait_seconds', $attributes); + self::assertArrayNotHasKey('durable.timer.lateness_seconds', $attributes); + self::assertArrayNotHasKey('private.payload', $attributes); + } + + private static function signalRun(): WorkflowRun + { + $run = new WorkflowRun(); + $run->setRawAttributes([ + 'id' => 'run-1', + 'workflow_instance_id' => 'instance-1', + 'namespace' => '0', + ]); + + return $run; + } + + private static function signalCommand(mixed $carrier, string $id = 'signal-1'): WorkflowCommand + { + $command = new WorkflowCommand(); + $command->setRawAttributes([ + 'id' => $id, + 'workflow_instance_id' => 'instance-1', + 'workflow_run_id' => 'run-1', + 'command_type' => 'signal', + 'status' => 'accepted', + 'observability_context' => json_encode($carrier, JSON_THROW_ON_ERROR), + ]); + + return $command; + } + + private static function fields(string $run = 'run-1', string $attempt = 'attempt-1'): array + { + return [ + 'workflow_instance_id' => 'instance-1', + 'workflow_run_id' => $run, + 'workflow_type' => 'test.workflow', + 'namespace' => '0', + 'task_id' => 'task-1', + 'activity_id' => 'activity-1', + 'activity_type' => 'test.activity', + 'activity_attempt_id' => $attempt, + 'activity_attempt_number' => 1, + 'request_id' => 'origin-request', + 'arguments' => 'must not be captured', + ]; + } + + private static function carrier(): array + { + return [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => [ + 'traceparent' => '00-' . self::TRACE_ID . '-' . self::PARENT_ID . '-01', + ], + ]; + } +} diff --git a/tests/Unit/V2/ExporterExecutionTracingTest.php b/tests/Unit/V2/ExporterExecutionTracingTest.php new file mode 100644 index 000000000..efb8901e4 --- /dev/null +++ b/tests/Unit/V2/ExporterExecutionTracingTest.php @@ -0,0 +1,255 @@ + ['unavailable'], + 'stalled collector' => ['stall'], + 'connection refused' => ['refused'], + ]; + } + + #[DataProvider('outages')] + public function testCollectorFailureDoesNotChangeColdWorkflowCompletion(string $mode): void + { + $this->requireExporter(); + $directory = $this->directory(); + [$server, $address] = $this->collector($directory, $mode); + if ($mode === 'refused') { + $server->stop(); + } + try { + $admitted = $this->worker($directory, 'admit', $address); + $workers = []; + for ($i = 0; $i < 3; ++$i) { + $workers[] = $this->worker($directory, 'work', $address); + } + $finished = $this->worker($directory, 'inspect', $address); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(3, $finished['tasks']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertCount(3, array_unique(array_column($workers, 'pid'))); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + $spans = array_merge(...array_column($workers, 'execution_spans')); + self::assertCount(3, $spans); + foreach ($spans as $span) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + } + $requests = $this->requests($directory); + self::assertCount($mode === 'refused' ? 0 : 4, $requests); + foreach ($requests as $index => $request) { + self::assertSame('POST', $request['method']); + self::assertSame('/v1/traces', $request['path']); + self::assertSame('application/x-protobuf', $request['content_type']); + self::assertCount($index < 3 ? 2 : 1, $request['spans']); + } + if ($mode !== 'refused') { + self::assertSame([ + 'durable.task.dispatch', 'durable.workflow.admission', + 'durable.task.dispatch', 'durable.workflow.task', + 'durable.task.dispatch', 'durable.activity.attempt', 'durable.workflow.task', + ], array_column(array_merge(...array_column($requests, 'spans')), 'name')); + } + } finally { + $server->stop(); + (new Filesystem())->deleteDirectory($directory); + } + } + + public function testBoundedQueueDropsOverflowAndRecoversWithoutReplayingBusinessWork(): void + { + $this->requireExporter(); + $directory = $this->directory(); + [$server, $address] = $this->collector($directory, 'unavailable'); + $initial = Context::getCurrent(); + $provider = new TracerProvider(TracingExporter::processor('http://' . $address . '/v1/traces')); + self::assertSame($initial, Context::getCurrent()); + $previousWriter = Logging::logWriter(); + $logs = new TestHandler(); + Logging::setLogWriter(new Psr3LogWriter(new Logger('telemetry-test', [$logs]))); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + $calls = 0; + try { + for ($i = 0; $i < 10; ++$i) { + self::assertSame(7, ExecutionTracing::activity([ + 'task_id' => 'task-' . $i, + 'arguments' => 'payload-private-sentinel', + ], null, static function () use (&$calls): int { + ++$calls; + + return 7; + }, enabled: true)); + } + $failure = new RuntimeException('business-private-sentinel'); + try { + ExecutionTracing::activity([], null, static function () use ($failure): void { + throw $failure; + }, enabled: true); + self::fail('The original business exception must escape.'); + } catch (RuntimeException $caught) { + self::assertSame($failure, $caught); + } + self::assertSame(10, $calls); + self::assertSame([], ExecutionTracing::current()); + self::assertSame([], $this->requests($directory)); + $provider->forceFlush(); + $failed = $this->requests($directory); + self::assertCount(2, $failed); + self::assertCount(4, array_merge(...array_column($failed, 'spans'))); + self::assertCount(2, $logs->getRecords()); + foreach ($logs->getRecords() as $record) { + self::assertSame('Export failure', $record['message']); + } + file_put_contents($directory . '/mode', 'healthy'); + ExecutionTracing::activity([ + 'task_id' => 'recovered', + ], null, static function () use (&$calls): void { + ++$calls; + }, enabled: true); + self::assertCount(2, $this->requests($directory)); + $provider->forceFlush(); + $requests = $this->requests($directory); + self::assertCount(3, $requests); + self::assertSame('healthy', $requests[2]['mode']); + self::assertCount(1, $requests[2]['spans']); + self::assertStringContainsString('recovered', $requests[2]['request']); + try { + ExecutionTracing::activity([], null, static function () use ($failure): void { + throw $failure; + }, enabled: true); + self::fail('Exporter recovery must preserve the original business exception.'); + } catch (RuntimeException $caught) { + self::assertSame($failure, $caught); + } + $provider->forceFlush(); + $requests = $this->requests($directory); + self::assertCount(4, $requests); + self::assertStringContainsString('error.type', $requests[3]['request']); + self::assertStringContainsString(RuntimeException::class, $requests[3]['request']); + foreach (['payload-private-sentinel', 'business-private-sentinel'] as $private) { + self::assertStringNotContainsString($private, json_encode($requests, JSON_THROW_ON_ERROR)); + } + self::assertSame(11, $calls); + $provider->shutdown(); + $provider->shutdown(); + self::assertCount(4, $this->requests($directory)); + self::assertSame([], ExecutionTracing::current()); + } finally { + $configuration->detach(); + $provider->shutdown(); + Logging::setLogWriter($previousWriter); + $server->stop(); + (new Filesystem())->deleteDirectory($directory); + } + self::assertSame($initial, Context::getCurrent()); + } + + private function requireExporter(): void + { + if (! class_exists(SpanExporter::class)) { + self::markTestSkipped('Requires the optional official OTLP exporter.'); + } + } + + private function directory(): string + { + $directory = sys_get_temp_dir() . '/dw-tracing-exporter-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + + return $directory; + } + + private function collector(string $directory, string $mode): array + { + file_put_contents($directory . '/mode', $mode); + $socket = stream_socket_server('tcp://127.0.0.1:0'); + self::assertIsResource($socket); + $address = stream_socket_get_name($socket, false); + self::assertIsString($address); + fclose($socket); + $server = new Process([ + PHP_BINARY, '-S', $address, __DIR__ . '/../../Fixtures/V2/tracing-collector-router.php', + ], env: [ + 'DW_TEST_COLLECTOR_DIRECTORY' => $directory, + 'DW_TEST_COLLECTOR_AUTOLOADS' => json_encode($this->autoloads(), JSON_THROW_ON_ERROR), + ]); + $server->setTimeout(15); + $server->start(); + self::assertTrue($server->waitUntil(static fn (string $type, string $output): bool => str_contains( + $output, + 'Development Server' + ))); + + return [$server, $address]; + } + + private function autoloads(): array + { + return array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders()) + ); + } + + private function requests(string $directory): array + { + $journal = $directory . '/requests.jsonl'; + + return is_file($journal) ? array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($journal, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ) : []; + } + + private function worker(string $directory, string $mode, string $address): array + { + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...$this->autoloads(), + ], env: [ + 'DW_TEST_TRACING_EXPORTER_ENDPOINT' => 'http://' . $address . '/v1/traces', + 'DW_TEST_TRACING_EXPORTER_JOURNAL' => $directory . '/requests.jsonl', + ]); + $process->setTimeout(15); + $process->mustRun(); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + self::assertSame(0, $result['exports_before_shutdown']); + self::assertLessThan(2, $result['export_shutdown_seconds']); + if ($mode === 'work') { + self::assertStringContainsString('Export failure', $process->getErrorOutput()); + } + + return $result; + } +} diff --git a/tests/Unit/V2/FailureOutcomeExecutionTracingTest.php b/tests/Unit/V2/FailureOutcomeExecutionTracingTest.php new file mode 100644 index 000000000..27c001fca --- /dev/null +++ b/tests/Unit/V2/FailureOutcomeExecutionTracingTest.php @@ -0,0 +1,163 @@ + [$kind, true, true]; + yield $kind . ' disabled' => [$kind, false, false]; + yield $kind . ' legacy' => [$kind, false, true]; + } + } + + #[DataProvider('modes')] + public function testColdReplayLinksTheOriginalHandledFailureWithoutAnotherCallback( + string $kind, + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-failure-outcomes-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + try { + $worker = self::worker($directory, $kind, $admissionEnabled, $workerEnabled); + $admitted = $worker('admit'); + $states = [$admitted]; + $workers = []; + $deadline = microtime(true) + 30; + do { + $state = $worker('inspect'); + if (in_array($state['status'], ['completed', 'failed', 'terminated'], true)) { + break; + } + if ($state['failure_outcomes']['ready_jobs'] === 0) { + usleep(100_000); + continue; + } + $states[] = $workers[] = $worker('work'); + } while (microtime(true) < $deadline); + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status'], json_encode($finished, JSON_THROW_ON_ERROR)); + self::assertSame([ + 'caught' => 'deliberate terminal activity failure', + 'value' => [9007199254740993, null, '雪'], + ], $finished['output']); + foreach (['jobs', 'failed_jobs', 'repairs'] as $field) { + self::assertSame(0, $finished[$field]); + } + $observed = $finished['failure_outcomes']; + self::assertCount(1, $observed['attempts']); + self::assertSame('failed', $observed['attempts'][0]['status']); + self::assertSame(1, $observed['attempts'][0]['attempt_number']); + self::assertSame(1, $observed['activities'][0]['attempt_count']); + self::assertSame( + [1, 2, 5, 10, 15, 30, 60, 120], + $observed['activities'][0]['retry_policy']['backoff_seconds'] + ); + $counts = array_count_values($observed['history_types']); + foreach (['ActivityFailed', 'FailureHandled', 'TimerFired', 'WorkflowCompleted'] as $type) { + self::assertSame(1, $counts[$type]); + } + self::assertCount(count($workers), array_unique(array_column($workers, 'pid'))); + foreach ($states as $state) { + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $state[$field]); + self::assertSame($admitted[$field], $finished[$field]); + } + self::assertSame($state['failure_outcomes']['history_hashes'], array_slice( + $observed['history_hashes'], + 0, + count($state['failure_outcomes']['history_hashes']), + )); + foreach ($state['failure_outcomes']['activities'] as $activity) { + self::assertSame($activity['retry_policy'], $observed['activities'][0]['retry_policy']); + } + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES), + ); + self::assertCount(1, array_filter($records, static fn (array $record): bool => + $record['message'] === 'terminal failure callback')); + self::assertFailureLinks($workers, $observed, $kind, $workerEnabled); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function worker(string $directory, string $kind, bool $admission, bool $enabled): callable + { + return static function (string $mode) use ($directory, $kind, $admission, $enabled): array { + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders()) + ), + ], env: [ + 'DW_TEST_TRACING_FAILURE_OUTCOMES' => $kind, + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admission : $enabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + } + + private static function assertFailureLinks(array $workers, array $observed, string $kind, bool $enabled): void + { + $spans = array_merge(...array_column($workers, 'spans')); + if (! $enabled) { + self::assertSame([], $spans); + self::assertSame([], $observed['outcome_contexts']); + self::assertSame(0, array_sum(array_map(static fn (array $state): int => + $state['failure_outcomes']['task_outcome_queries'], $workers))); + return; + } + $callbacks = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === ($kind === 'local' ? 'durable.local_activity.attempt' : 'durable.activity.attempt'))); + self::assertCount(1, $callbacks); + self::assertSame(StatusCode::STATUS_ERROR, $callbacks[0]['status_code']); + $events = array_column($observed['outcome_events'], null, 'type'); + self::assertSame($observed['attempts'][0]['id'], $events['ActivityFailed']['payload']['activity_attempt_id']); + $failure = $events['ActivityFailed']; + self::assertSame($failure['payload']['activity_attempt_id'], $callbacks[0]['fields']['activity_attempt_id']); + self::assertSame($failure['task_id'], $callbacks[0]['fields']['task_id']); + $final = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === 'durable.workflow.task' + && $span['fields']['task_id'] === $events['WorkflowCompleted']['task_id'])); + self::assertCount(1, $final); + $links = []; + foreach ($final[0]['links'] as $link) { + if (($link['fields']['durable.link.type'] ?? null) === 'task_outcome') { + $links[$link['fields']['history_event_id']] = $link; + } + } + self::assertCount(2, $links); + self::assertArrayHasKey($failure['id'], $links); + self::assertSame($callbacks[0]['trace_id'], $links[$failure['id']]['trace_id']); + self::assertSame($callbacks[0]['span_id'], $links[$failure['id']]['span_id']); + self::assertSame($failure['task_id'], $links[$failure['id']]['fields']['task_id']); + self::assertArrayHasKey($events['TimerFired']['id'], $links); + self::assertStringNotContainsString( + 'deliberate terminal activity failure', + json_encode($spans, JSON_THROW_ON_ERROR) + ); + } +} diff --git a/tests/Unit/V2/FanOutExecutionTracingTest.php b/tests/Unit/V2/FanOutExecutionTracingTest.php new file mode 100644 index 000000000..1caa3c3cd --- /dev/null +++ b/tests/Unit/V2/FanOutExecutionTracingTest.php @@ -0,0 +1,311 @@ + [true, true], + 'disabled' => [false, false], + 'legacy admission' => [false, true], + 'disabled workers' => [true, false], + ]; + } + + #[DataProvider('modes')] + public function testColdParallelMembersPreserveTheirOwnAttemptScopesAndRecordedResults( + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-fan-out-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $admissionEnabled, $workerEnabled): array { + $autoloads = array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders()), + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...$autoloads, + ], env: [ + 'DW_TEST_TRACING_FAN_OUT' => 'true', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame($admissionEnabled, $admitted['carrier'] !== null); + $states = [$admitted]; + $workers = []; + $deadline = microtime(true) + 30; + do { + $state = $worker('inspect'); + if (in_array($state['status'], ['completed', 'failed', 'cancelled', 'terminated'], true)) { + break; + } + if ($state['fan_out']['ready_jobs'] === 0) { + usleep(100_000); + continue; + } + $states[] = $workers[] = $worker('work'); + } while (microtime(true) < $deadline); + $finished = $worker('inspect'); + self::assertDurableResult($admitted, $states, $workers, $finished); + self::assertAttemptScopes( + $admitted, + $workers, + $finished['fan_out']['attempts'], + $admissionEnabled, + $workerEnabled + ); + self::assertOutcomeLinks($finished, $workers, $workerEnabled); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function assertDurableResult(array $admitted, array $states, array $workers, array $finished): void + { + self::assertSame('completed', $finished['status'], json_encode([ + 'run_id' => $finished['run_id'], + 'jobs' => $finished['jobs'], + 'history_types' => $finished['fan_out']['history_types'], + 'last_event' => end($finished['fan_out']['history_payloads']), + ], JSON_THROW_ON_ERROR)); + self::assertSame(array_fill_keys(['ordinary', 'retry', 'local'], [9007199254740993, null, '雪']) + [ + 'timer' => true, + ], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + $fanOut = $finished['fan_out']; + self::assertCount(3, $fanOut['activities']); + self::assertCount(4, $fanOut['attempts']); + $attemptCounts = array_column($fanOut['activities'], 'attempt_count'); + sort($attemptCounts); + self::assertSame([1, 1, 2], $attemptCounts); + self::assertSame(3, count(array_filter( + $fanOut['history_types'], + static fn (string $type): bool => $type === 'ActivityCompleted', + ))); + self::assertSame(1, count(array_filter( + $fanOut['history_types'], + static fn (string $type): bool => $type === 'TimerFired', + ))); + self::assertSame(1, count(array_filter( + $fanOut['history_types'], + static fn (string $type): bool => $type === 'WorkflowCompleted', + ))); + $activitiesById = array_column($fanOut['activities'], null, 'id'); + foreach ($states as $state) { + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $state[$field]); + self::assertSame($admitted[$field], $finished[$field]); + } + self::assertSame($state['fan_out']['history_hashes'], array_slice( + $fanOut['history_hashes'], + 0, + count($state['fan_out']['history_hashes']), + )); + foreach ($state['fan_out']['activities'] as $activity) { + self::assertArrayHasKey($activity['id'], $activitiesById); + self::assertSame($activity['retry_policy'], $activitiesById[$activity['id']]['retry_policy']); + } + } + $retryActivity = array_values(array_filter( + $fanOut['activities'], + static fn (array $activity): bool => $activity['attempt_count'] === 2, + ))[0]; + $retryAttempts = array_values(array_filter( + $fanOut['attempts'], + static fn (array $attempt): bool => $attempt['activity_execution_id'] === $retryActivity['id'], + )); + self::assertSame([1, 2], array_column($retryAttempts, 'attempt_number')); + $tasksById = array_column($fanOut['tasks'], null, 'id'); + foreach ($retryAttempts as $attempt) { + self::assertArrayHasKey($attempt['workflow_task_id'], $tasksById); + } + foreach ($fanOut['tasks'] as $task) { + self::assertGreaterThanOrEqual(1, $task['attempt_count']); + self::assertSame(0, $task['repair_count']); + } + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + self::assertGreaterThan(2, count($workers)); + self::assertStringNotContainsString( + 'observability_context', + json_encode($fanOut['history_payloads'], JSON_THROW_ON_ERROR), + ); + } + + private static function assertAttemptScopes( + array $admitted, + array $workers, + array $recordedAttempts, + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $spans = array_merge(...array_column($workers, 'spans')); + if (! $workerEnabled) { + self::assertSame([], $spans); + + return; + } + $attempts = array_values(array_filter($spans, static fn (array $span): bool => in_array( + $span['name'], + ['durable.activity.attempt', 'durable.local_activity.attempt'], + true, + ))); + self::assertCount(4, $attempts); + self::assertCount(1, array_filter( + $attempts, + static fn (array $span): bool => $span['status_code'] === StatusCode::STATUS_ERROR, + )); + self::assertCount(4, array_unique(array_column($attempts, 'span_id'))); + self::assertCount(4, array_unique(array_map( + static fn (array $span): string => $span['fields']['activity_attempt_id'], + $attempts, + ))); + $durableAttempts = array_column($recordedAttempts, null, 'id'); + foreach ($workers as $state) { + $workflowSpans = array_values(array_filter( + $state['spans'], + static fn (array $span): bool => $span['name'] === 'durable.workflow.task', + )); + $stateAttempts = array_filter($state['spans'], static fn (array $span): bool => in_array( + $span['name'], + ['durable.activity.attempt', 'durable.local_activity.attempt'], + true, + )); + foreach ($stateAttempts as $span) { + $fields = $span['fields']; + self::assertSame($admitted['run_id'], $fields['workflow_run_id']); + self::assertSame('cold-tracing', $fields['workflow_instance_id']); + self::assertSame('0', $fields['namespace']); + $recorded = $durableAttempts[$fields['activity_attempt_id']]; + self::assertSame($recorded['activity_execution_id'], $fields['activity_id']); + self::assertSame($recorded['attempt_number'], $fields['activity_attempt_number']); + if ($span['name'] === 'durable.local_activity.attempt') { + self::assertCount(1, $workflowSpans); + self::assertSame($workflowSpans[0]['span_id'], $span['parent_id']); + self::assertSame($workflowSpans[0]['fields']['task_id'], $fields['task_id']); + } elseif ($admissionEnabled) { + self::assertSame($recorded['workflow_task_id'], $fields['task_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame($recorded['workflow_task_id'], $fields['task_id']); + self::assertSame('0000000000000000', $span['parent_id']); + } + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + } else { + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + } + } + } + + private static function assertOutcomeLinks(array $finished, array $workers, bool $enabled): void + { + $rows = $finished['fan_out']['outcome_contexts']; + self::assertStringNotContainsString( + 'observability_context', + json_encode($finished['fan_out']['public_tasks'], JSON_THROW_ON_ERROR), + ); + if (! $enabled) { + self::assertSame([], $rows); + self::assertSame(0, array_sum(array_map( + static fn (array $state): int => $state['fan_out']['task_outcome_queries'], + $workers, + ))); + return; + } + self::assertGreaterThan(0, array_sum(array_map( + static fn (array $state): int => $state['fan_out']['task_outcome_queries'], + $workers, + ))); + $contexts = []; + foreach ($rows as $row) { + $envelope = json_decode($row['context'], true, flags: JSON_THROW_ON_ERROR); + self::assertSame('durable-workflow.task-outcomes', $envelope['schema']); + self::assertSame(1, $envelope['version']); + foreach (['task_id', 'run_id', 'namespace'] as $field) { + self::assertSame($row[$field], $envelope[$field]); + } + self::assertLessThanOrEqual(4096, strlen($row['context'])); + foreach ($envelope['outcomes'] as $entry) { + $contexts[$entry['event_id']] = $entry + [ + 'task_id' => $row['task_id'], + ]; + } + } + self::assertCount(5, $contexts); + $spans = array_column(array_merge(...array_column($workers, 'spans')), null, 'span_id'); + $contributions = []; + $final = null; + foreach ($finished['fan_out']['outcome_events'] as $event) { + self::assertArrayHasKey($event['id'], $contexts); + $entry = $contexts[$event['id']]; + self::assertSame($event['sequence'], $entry['sequence']); + self::assertSame($event['type'], $entry['event_type']); + self::assertSame($event['task_id'], $entry['task_id']); + $context = Span::fromContext(TraceContextPropagator::getInstance()->extract( + $entry['context']['carrier'], + context: Context::getRoot(), + ))->getContext(); + self::assertArrayHasKey($context->getSpanId(), $spans); + $span = $spans[$context->getSpanId()]; + self::assertSame($span['trace_id'], $context->getTraceId()); + self::assertSame($event['task_id'], $span['fields']['task_id']); + if ($event['type'] === 'WorkflowCompleted') { + self::assertSame('durable.workflow.task', $span['name']); + $final = $span; + } else { + $contributions[$event['id']] = [ + 'trace_id' => $span['trace_id'], + 'span_id' => $span['span_id'], + ]; + if ($event['type'] === 'ActivityCompleted') { + self::assertSame($event['payload']['activity_attempt_id'], $span['fields']['activity_attempt_id']); + } else { + self::assertSame('durable.timer.task', $span['name']); + self::assertSame($event['payload']['timer_id'], $span['fields']['timer_id']); + } + } + } + self::assertNotNull($final); + $links = []; + foreach ($final['links'] as $link) { + if (($link['fields']['durable.link.type'] ?? null) === 'task_outcome') { + $links[$link['fields']['history_event_id']] = [ + 'trace_id' => $link['trace_id'], + 'span_id' => $link['span_id'], + ]; + } + } + ksort($links); + ksort($contributions); + self::assertSame($contributions, $links); + } +} diff --git a/tests/Unit/V2/HttpExecutionTracingTest.php b/tests/Unit/V2/HttpExecutionTracingTest.php new file mode 100644 index 000000000..780403e26 --- /dev/null +++ b/tests/Unit/V2/HttpExecutionTracingTest.php @@ -0,0 +1,129 @@ +setTimeout(5); + $server->start(); + try { + self::assertTrue($server->waitUntil(static fn (string $type, string $output): bool => str_contains( + $output, + 'Development Server' + ))); + $admitted = $this->runWorker($directory, 'admit', $address); + $workflow = $this->runWorker($directory, 'work', $address); + $activity = $this->runWorker($directory, 'work', $address); + $completion = $this->runWorker($directory, 'work', $address); + $finished = $this->runWorker($directory, 'inspect', $address); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(3, $finished['tasks']); + self::assertCount(3, array_unique([$workflow['pid'], $activity['pid'], $completion['pid']])); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + $attempts = array_values(array_filter( + $activity['spans'], + static fn (array $span): bool => $span['name'] === 'durable.activity.attempt' + )); + $clients = array_values(array_filter( + $activity['spans'], + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_CLIENT + )); + self::assertCount(1, $attempts); + self::assertCount(2, $clients); + self::assertSame($attempts[0]['span_id'], $clients[0]['parent_id']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $clients[0]['trace_id']); + self::assertSame( + '00-' . $clients[0]['trace_id'] . '-' . $clients[0]['span_id'] . '-01', + $activity['http_response']['traceparent'] + ); + self::assertSame('dwtest=one', $activity['http_response']['tracestate']); + self::assertSame('approved=tenant-a', $activity['http_response']['baggage']); + self::assertSame('activity-http-request', $activity['http_response']['request_id']); + self::assertSame('11111111111111111111111111111111', $clients[1]['trace_id']); + self::assertSame('2222222222222222', $clients[1]['parent_id']); + self::assertSame( + '00-' . $clients[1]['trace_id'] . '-' . $clients[1]['span_id'] . '-01', + $activity['outside_http']['traceparent'] + ); + self::assertNull($activity['outside_http']['baggage']); + self::assertSame('outside-http-request', $activity['outside_http']['request_id']); + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $transfers = array_values(array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'http transfer' + )); + self::assertCount(1, $transfers); + $fields = $transfers[0]['context']; + self::assertSame($admitted['run_id'], $fields['workflow_run_id']); + self::assertSame($attempts[0]['fields']['activity_attempt_id'], $fields['activity_attempt_id']); + self::assertSame('activity-http-request', $fields['request_id']); + self::assertSame($fields['active_trace_id'], $fields['trace_id']); + self::assertSame($fields['active_span_id'], $fields['span_id']); + $telemetry = json_encode($activity['spans'], JSON_THROW_ON_ERROR); + foreach (['test-auth-sentinel', 'payload-private-sentinel', 'must-not-propagate'] as $excluded) { + self::assertStringNotContainsString($excluded, $telemetry); + } + } finally { + $server->stop(); + (new Filesystem())->deleteDirectory($directory); + } + } + + private function runWorker(string $directory, string $mode, string $address): array + { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_HTTP_URL' => 'http://' . $address . '/headers', + ]); + $process->setTimeout(30); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + } +} diff --git a/tests/Unit/V2/IncomingHttpTracingTest.php b/tests/Unit/V2/IncomingHttpTracingTest.php new file mode 100644 index 000000000..4b6414317 --- /dev/null +++ b/tests/Unit/V2/IncomingHttpTracingTest.php @@ -0,0 +1,174 @@ +autoloads(); + $socket = stream_socket_server('tcp://127.0.0.1:0'); + self::assertIsResource($socket); + $address = stream_socket_get_name($socket, false); + self::assertIsString($address); + fclose($socket); + $server = new Process([ + PHP_BINARY, '-S', $address, __DIR__ . '/../../Fixtures/V2/tracing-process.php', + ], env: [ + 'DW_TEST_TRACING_DIRECTORY' => $directory, + 'DW_TEST_TRACING_AUTOLOADS' => json_encode($autoloads, JSON_THROW_ON_ERROR), + ]); + $server->setTimeout(10); + try { + $prepared = $this->worker($directory, 'prepare-http', $autoloads); + self::assertSame([ + 'workflow_runs' => 0, + 'commands' => 0, + 'history_events' => 0, + 'tasks' => 0, + 'jobs' => 0, + ], $prepared); + $server->start(); + self::assertTrue($server->waitUntil(static fn (string $type, string $output): bool => str_contains( + $output, + 'Development Server' + ))); + $response = (new Client())->post('http://' . $address . '/start', [ + 'timeout' => 10, + 'connect_timeout' => 1, + 'headers' => [ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + 'tracestate' => 'dwtest=one', + 'baggage' => 'approved=tenant-a,private=must-not-propagate', + 'X-Request-ID' => 'incoming-http-request', + ], + ]); + self::assertSame(201, $response->getStatusCode()); + $admitted = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + $server->stop(); + self::assertFalse($server->isRunning()); + self::assertSame([], $admitted['outside_context']); + self::assertTrue($admitted['scope_restored']); + self::assertCount(2, $admitted['execution_spans']); + $requestSpans = array_values(array_filter( + $admitted['execution_spans'], + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_SERVER + )); + $admissionSpans = array_values(array_filter( + $admitted['execution_spans'], + static fn (array $span): bool => $span['name'] === 'durable.workflow.admission' + )); + self::assertCount(1, $requestSpans); + self::assertCount(1, $admissionSpans); + $requestSpan = $requestSpans[0]; + $admissionSpan = $admissionSpans[0]; + self::assertSame(SpanKind::KIND_SERVER, $requestSpan['kind']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $requestSpan['trace_id']); + self::assertSame('00f067aa0ba902b7', $requestSpan['parent_id']); + self::assertSame(SpanKind::KIND_INTERNAL, $admissionSpan['kind']); + self::assertSame($requestSpan['trace_id'], $admissionSpan['trace_id']); + self::assertSame($requestSpan['span_id'], $admissionSpan['parent_id']); + self::assertSame($admitted['run_id'], $admissionSpan['fields']['workflow_run_id']); + self::assertSame('started_new', $admissionSpan['fields']['durable.command.outcome']); + $carrier = json_decode($admitted['carrier'], true, flags: JSON_THROW_ON_ERROR)['carrier']; + self::assertSame( + '00-' . $requestSpan['trace_id'] . '-' . $requestSpan['span_id'] . '-01', + $carrier['traceparent'] + ); + self::assertSame('dwtest=one', $carrier['tracestate']); + self::assertSame('approved=tenant-a', $carrier['baggage']); + $workers = []; + for ($remaining = 5; $remaining > 0; --$remaining) { + $workers[] = $this->worker($directory, 'work', $autoloads); + } + self::assertCount(5, array_unique(array_column($workers, 'pid'))); + $activityIds = []; + $activitySpans = []; + foreach ($workers as $worker) { + self::assertSame([], $worker['outside_context']); + self::assertTrue($worker['scope_restored']); + self::assertCount(1, $worker['execution_spans']); + $span = $worker['execution_spans'][0]; + self::assertSame($requestSpan['trace_id'], $span['trace_id']); + self::assertSame($requestSpan['span_id'], $span['parent_id']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + if ($span['name'] === 'durable.activity.attempt') { + self::assertSame(1, $span['fields']['activity_attempt_number']); + $activityIds[] = $span['fields']['activity_id']; + $activitySpans[$span['fields']['activity_id']] = $span; + } + } + self::assertCount(2, array_unique($activityIds)); + $finished = $this->worker($directory, 'inspect', $autoloads); + self::assertSame('completed', $finished['status']); + self::assertSame([ + [9007199254740993, null, '雪'], [9007199254740993, null, '雪'], + ], $finished['output']); + self::assertSame(5, $finished['tasks']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_values(array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'callback' + )); + self::assertCount(2, $callbacks); + foreach ($callbacks as $callback) { + $fields = $callback['context']; + self::assertSame($admitted['run_id'], $fields['workflow_run_id']); + self::assertArrayHasKey($fields['activity_id'], $activitySpans); + $span = $activitySpans[$fields['activity_id']]; + self::assertSame($span['fields']['activity_attempt_id'], $fields['activity_attempt_id']); + self::assertSame($requestSpan['trace_id'], $fields['trace_id']); + self::assertSame($span['span_id'], $fields['span_id']); + } + } finally { + $server->stop(); + (new Filesystem())->deleteDirectory($directory); + } + } + + private function worker(string $directory, string $mode, array $autoloads): array + { + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', ...$autoloads, + ]); + $process->setTimeout(30); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + + return json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + } + + private function autoloads(): array + { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + + return array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ); + } +} diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php new file mode 100644 index 000000000..23bec5c28 --- /dev/null +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -0,0 +1,322 @@ + [true], + 'embedded disabled' => [false], + ]; + } + + #[DataProvider('modes')] + public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeContexts(bool $enabled): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-large-fan-out-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $batch = []; + try { + $admitted = self::read(self::process($directory, 'admit', $enabled)); + self::assertTrue($admitted['large_fan_out']['matching_role']['queue_wake_enabled']); + self::assertSame('in_worker', $admitted['large_fan_out']['matching_role']['shape']); + $deadline = microtime(true) + 90; + $initial = self::read(self::process($directory, 'work', $enabled, $deadline)); + self::assertCount(64, $initial['large_fan_out']['activities']); + self::assertSame(64, $initial['large_fan_out']['ready_jobs']); + $spans = $initial['spans']; + $queueErrors = $initial['large_fan_out']['queue_errors']; + $reportedQueueErrors = $initial['large_fan_out']['reported_queue_errors']; + $queueDiagnosticsTruncated = $initial['large_fan_out']['queue_diagnostics_truncated']; + $prefixes = [$admitted['large_fan_out']['history_hashes'], $initial['large_fan_out']['history_hashes']]; + $pids = []; + $queries = $initial['large_fan_out']['task_outcome_queries']; + for ($index = 0; $index < 4; ++$index) { + $batch[] = $process = self::process($directory, 'work-drain', $enabled, $deadline); + $process->start(); + } + self::assertCount(4, array_filter($batch, static fn (Process $process): bool => $process->isRunning())); + self::observeCompletion($directory, $admitted['run_id'], $deadline); + file_put_contents($directory . '/stop-large-workers', 'stop'); + foreach ($batch as $process) { + $result = self::read($process); + $spans = array_merge($spans, $result['spans']); + $queueErrors = array_merge($queueErrors, $result['large_fan_out']['queue_errors']); + $reportedQueueErrors = array_merge( + $reportedQueueErrors, + $result['large_fan_out']['reported_queue_errors'] + ); + $queueDiagnosticsTruncated = $queueDiagnosticsTruncated + || $result['large_fan_out']['queue_diagnostics_truncated']; + $prefixes[] = $result['large_fan_out']['history_hashes']; + $pids[] = $result['pid']; + $queries += $result['large_fan_out']['task_outcome_queries']; + } + $finished = self::read(self::process($directory, 'inspect', $enabled, $deadline)); + $lastEvent = end($finished['large_fan_out']['history_payloads']); + $diagnostic = json_encode([ + 'run_id' => $finished['run_id'], + 'jobs' => $finished['jobs'], + 'status' => $finished['status'], + 'failed_jobs' => $finished['failed_jobs'], + 'ready_jobs' => $finished['large_fan_out']['ready_jobs'], + 'reserved_jobs' => $finished['large_fan_out']['reserved_jobs'], + 'delayed_jobs' => $finished['large_fan_out']['delayed_jobs'], + 'queue_attempts' => $finished['large_fan_out']['queue_attempts'], + 'queue_errors' => $queueErrors, + 'reported_queue_errors' => $reportedQueueErrors, + 'remaining_deliveries' => $finished['large_fan_out']['remaining_deliveries'], + 'delivery_errors' => $finished['large_fan_out']['delivery_errors'], + 'repairs' => $finished['repairs'], + 'task_states' => array_count_values(array_column($finished['large_fan_out']['tasks'], 'status')), + 'activity_states' => array_count_values( + array_column($finished['large_fan_out']['activities'], 'status') + ), + 'history_counts' => array_count_values($finished['large_fan_out']['history_types']), + 'last_event' => is_array($lastEvent) ? array_intersect_key( + $lastEvent, + array_fill_keys(['exception_class', 'message', 'reason'], true) + ) : $lastEvent, + ], JSON_THROW_ON_ERROR); + self::assertLessThan($deadline, microtime(true), 'Original 90-second observer elapsed. ' . $diagnostic); + self::assertSame('completed', $finished['status'], $diagnostic); + self::assertSame(array_map(static fn (int $member): array => [ + 'member' => $member, + 'value' => [9007199254740993, null, '雪'], + ], range(0, 63)), $finished['output']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $finished[$field]); + } + $observed = $finished['large_fan_out']; + self::assertCount(64, $observed['activities']); + self::assertCount(64, $observed['attempts']); + self::assertSame([1], array_values(array_unique(array_column($observed['attempts'], 'attempt_number')))); + self::assertCount(count($pids), array_unique($pids)); + self::assertCount(4, glob($directory . '/ready-worker-*')); + foreach ($prefixes as $prefix) { + self::assertSame($prefix, array_slice($observed['history_hashes'], 0, count($prefix))); + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_values(array_filter($records, static fn (array $record): bool => + $record['message'] === 'large callback')); + self::assertCount(64, $callbacks); + foreach ($callbacks as $callback) { + self::assertContains($callback['context']['actor_pid'], $pids); + } + $members = array_map(static fn (array $record): int => $record['context']['member'], $callbacks); + sort($members); + self::assertSame(range(0, 63), $members); + self::assertTraceWindow($spans, $observed, $enabled, $queries); + self::assertFalse($queueDiagnosticsTruncated, $diagnostic); + self::assertSupportedQueueRecovery($finished, $observed, $queueErrors, $reportedQueueErrors, $diagnostic); + } catch (Throwable $exception) { + throw new RuntimeException( + $exception->getMessage() . ' Read-only failure snapshot: ' . self::failureSnapshot($directory), + previous: $exception, + ); + } finally { + foreach ($batch as $process) { + $process->stop(1, 9); + } + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function assertSupportedQueueRecovery( + array $finished, + array $observed, + array $queueErrors, + array $reportedErrors, + string $diagnostic, + ): void { + foreach ($queueErrors as $error) { + self::assertQueuePressureError($error, false, $diagnostic); + } + foreach ($reportedErrors as $error) { + self::assertQueuePressureError($error, true, $diagnostic); + } + self::assertCount($finished['failed_jobs'], $observed['delivery_errors'], $diagnostic); + self::assertCount($finished['jobs'], $observed['remaining_deliveries'], $diagnostic); + $tasks = array_column($observed['tasks'], null, 'id'); + $errors = array_column($queueErrors, null, 'delivery_id'); + foreach ($observed['delivery_errors'] as $failed) { + self::assertRecoveredTask($failed, $tasks, $finished['run_id'], $diagnostic); + self::assertArrayHasKey($failed['delivery_id'], $errors, $diagnostic); + self::assertSame('update', $errors[$failed['delivery_id']]['operation'], $diagnostic); + self::assertStringContainsString('SqliteTransaction::forTask', implode("\n", $failed['stage_frames'])); + } + foreach ($observed['remaining_deliveries'] as $remaining) { + self::assertRecoveredTask($remaining, $tasks, $finished['run_id'], $diagnostic); + $fence = [ + (int) $remaining['id'], $remaining['queue'], + (int) $remaining['reserved_at'], $remaining['attempts'], + ]; + $refusals = array_filter($reportedErrors, static fn (array $error): bool => + $error['operation'] === 'delete' && $error['ack_fence'] === $fence); + self::assertNotEmpty($refusals, $diagnostic); + self::assertGreaterThan(time(), $remaining['reserved_at'] + $observed['retry_after_seconds'], $diagnostic); + } + } + + private static function assertQueuePressureError(array $error, bool $reported, string $diagnostic): void + { + self::assertIsInt($error['native_code'], $diagnostic); + self::assertContains($error['native_code'] & 255, [5, 6], $diagnostic); + self::assertTrue(in_array($error['operation'], ['update', 'delete'], true) + || ($reported && TracingQueueErrorDiagnostics::isFailedJobLogContention($error)), $diagnostic); + self::assertSame(0, $error['transaction_level'], $diagnostic); + self::assertFalse($error['pdo_in_transaction'], $diagnostic); + } + + private static function assertRecoveredTask(array $delivery, array $tasks, string $runId, string $diagnostic): void + { + self::assertIsString($delivery['task_id'], $diagnostic); + self::assertArrayHasKey($delivery['task_id'], $tasks, $diagnostic); + $task = $tasks[$delivery['task_id']]; + self::assertSame($runId, $task['workflow_run_id'], $diagnostic); + self::assertSame('0', $task['namespace'], $diagnostic); + self::assertSame('default', $task['queue'], $diagnostic); + self::assertSame('completed', $task['status'], $diagnostic); + self::assertSame(1, $task['attempt_count'], $diagnostic); + } + + private static function process(string $directory, string $mode, bool $enabled, ?float $deadline = null): Process + { + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders()) + ), + ], env: [ + 'DW_TEST_TRACING_LARGE_FAN_OUT' => 'true', + // Disabling this role requires a separate matching process. + 'DW_TEST_TRACING_LARGE_FAN_OUT_QUEUE_WAKE' => 'true', + 'DW_TEST_TRACING_CONTINUOUS_WORKERS' => 'true', + 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', + ]); + $process->setTimeout($deadline === null ? 20 : max(0.001, $deadline - microtime(true))); + return $process; + } + + private static function observeCompletion(string $directory, string $runId, float $deadline): void + { + $database = new PDO('sqlite:' . $directory . '/database.sqlite'); + $database->exec('PRAGMA busy_timeout=50'); + do { + $query = null; + try { + // A statement whose execution returned SQLITE_BUSY cannot be reused safely by PDO SQLite. + $query = $database->prepare('SELECT status FROM workflow_runs WHERE id = ?'); + $query->execute([$runId]); + $status = $query->fetchColumn(); + $query->closeCursor(); + if ($status === 'completed') { + return; + } + } catch (PDOException $exception) { + $code = $exception->errorInfo[1] ?? null; + if (! is_int($code) || ! in_array($code & 255, [5, 6], true)) { + throw $exception; + } + } finally { + $query = null; + } + usleep(100_000); + } while (microtime(true) < $deadline); + } + + private static function failureSnapshot(string $directory): string + { + try { + $database = new PDO('sqlite:file:' . $directory . '/database.sqlite?mode=ro'); + $database->exec('PRAGMA busy_timeout=25'); + $database->exec('PRAGMA query_only=ON'); + $snapshot = []; + foreach (['workflow_runs', 'workflow_tasks', 'activity_executions'] as $table) { + $snapshot[$table] = $database->query('SELECT status, COUNT(*) AS count FROM ' . $table + . ' GROUP BY status')->fetchAll(PDO::FETCH_ASSOC); + } + $snapshot['jobs'] = (int) $database->query('SELECT COUNT(*) FROM jobs')->fetchColumn(); + $snapshot['failed_jobs'] = (int) $database->query('SELECT COUNT(*) FROM failed_jobs')->fetchColumn(); + + return json_encode($snapshot, JSON_THROW_ON_ERROR); + } catch (Throwable $exception) { + return json_encode([ + 'unavailable' => $exception->getMessage(), + ], JSON_THROW_ON_ERROR); + } + } + + private static function read(Process $process): array + { + if (! $process->isStarted()) { + $process->mustRun(); + } else { + $process->wait(); + self::assertTrue($process->isSuccessful(), substr($process->getErrorOutput(), -4096)); + } + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + return $result; + } + + private static function assertTraceWindow(array $spans, array $observed, bool $enabled, int $queries): void + { + if (! $enabled) { + self::assertSame([], $spans); + self::assertSame([], $observed['outcome_contexts']); + self::assertSame(0, $queries); + return; + } + $callbacks = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === 'durable.activity.attempt')); + self::assertCount(64, $callbacks); + $attempts = array_column($observed['attempts'], null, 'id'); + foreach ($callbacks as $span) { + self::assertArrayHasKey($span['fields']['activity_attempt_id'], $attempts); + self::assertSame( + $attempts[$span['fields']['activity_attempt_id']]['workflow_task_id'], + $span['fields']['task_id'] + ); + } + $events = array_column($observed['outcome_events'], null, 'id'); + $completion = array_values(array_filter($events, static fn (array $event): bool => + $event['type'] === 'WorkflowCompleted'))[0]; + $parent = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === 'durable.workflow.task' && $span['fields']['task_id'] === $completion['task_id']))[0]; + $links = array_values(array_filter($parent['links'], static fn (array $link): bool => + ($link['fields']['durable.link.type'] ?? null) === 'task_outcome')); + self::assertCount(32, $links); + foreach ($links as $link) { + $event = $events[$link['fields']['history_event_id']]; + self::assertSame('ActivityCompleted', $event['type']); + $callback = array_values(array_filter($callbacks, static fn (array $span): bool => + $span['fields']['task_id'] === $event['task_id']))[0]; + self::assertSame($callback['trace_id'], $link['trace_id']); + self::assertSame($callback['span_id'], $link['span_id']); + } + self::assertArrayHasKey('durable.links.outcome_context_unavailable', $parent['fields']); + self::assertTrue($parent['fields']['durable.links.outcome_context_unavailable']); + } +} diff --git a/tests/Unit/V2/RecoveryExecutionTracingTest.php b/tests/Unit/V2/RecoveryExecutionTracingTest.php new file mode 100644 index 000000000..ebea59178 --- /dev/null +++ b/tests/Unit/V2/RecoveryExecutionTracingTest.php @@ -0,0 +1,215 @@ + [true], + 'disabled tracing control' => [false], + ]; + } + + #[DataProvider('tracingModes')] + public function testWorkerCrashThenAuthoredTimeoutRecoversContextInAColdRetry(bool $tracing): void + { + $this->tracing = $tracing; + $directory = sys_get_temp_dir() . '/dw-tracing-recovery-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $crashed = null; + try { + $admitted = $this->worker($directory, 'admit'); + $scheduled = $this->worker($directory, 'work'); + self::assertSame('waiting', $scheduled['status'], json_encode($scheduled, JSON_THROW_ON_ERROR)); + $crashed = $this->process($directory, 'work', true); + $crashed->start(); + $marker = $directory . '/claimed.json'; + $limit = hrtime(true) + 5_000_000_000; + while (! is_file($marker) && hrtime(true) < $limit && $crashed->isRunning()) { + usleep(20_000); + } + self::assertFileExists($marker, substr($crashed->getErrorOutput() . $crashed->getOutput(), -4096)); + $firstFields = json_decode(file_get_contents($marker), true, flags: JSON_THROW_ON_ERROR); + self::assertSame($crashed->getPid(), $firstFields['actor_pid']); + $crashed->signal(9); + try { + $crashed->wait(); + } catch (ProcessSignaledException $error) { + self::assertSame(9, $error->getSignal()); + } + self::assertTrue($crashed->hasBeenSignaled()); + self::assertSame(9, $crashed->getTermSignal()); + $first = $this->worker($directory, 'inspect'); + self::assertSame('running', $first['recovery']['attempts'][0]['status']); + self::assertSame($admitted['run_id'], $firstFields['workflow_run_id']); + if ($tracing) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $firstFields['trace_id']); + } else { + self::assertSame([], $firstFields['tracing_context']); + self::assertNull($admitted['carrier']); + } + self::assertSame(1, $firstFields['activity_attempt_number']); + self::assertSame($firstFields['activity_attempt_id'], $first['recovery']['attempts'][0]['id']); + self::assertSame($firstFields['task_id'], $first['recovery']['attempts'][0]['task_id']); + $deadline = $this->timestamp($first['recovery']['activities'][0]['close_deadline_at']); + $remaining = max(0.0, $deadline - microtime(true)); + self::assertLessThan(3.0, $remaining); + usleep((int) ceil($remaining * 1_000_000) + 50_000); + $repaired = $this->worker($directory, 'repair'); + self::assertSame(1, $repaired['recovery']['activities'][0]['attempt_count']); + self::assertSame(1, $repaired['recovery']['repair_report']['activity_timeouts_enforced']); + self::assertSame([], $repaired['recovery']['repair_report']['activity_timeout_failures']); + $workers = []; + for ($remainingWorkers = 6; $remainingWorkers > 0; --$remainingWorkers) { + $state = $this->worker($directory, 'inspect'); + if ($state['jobs'] === 0) { + break; + } + $workers[] = $this->worker($directory, 'work'); + } + $finished = $this->worker($directory, 'inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['repairs'], json_encode($finished['recovery'], JSON_THROW_ON_ERROR)); + self::assertSame(0, $finished['jobs']); + // The killed Laravel delivery exhausts the job's fixed transport retry budget. + self::assertSame(1, $finished['failed_jobs'], json_encode($finished['recovery']['failed_deliveries'])); + self::assertStringStartsWith( + 'Illuminate\\Queue\\MaxAttemptsExceededException:', + $finished['recovery']['failed_deliveries'][0]['exception'] + ); + self::assertStringContainsString( + $firstFields['task_id'], + $finished['recovery']['failed_deliveries'][0]['payload'] + ); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + self::assertCount(1, $finished['recovery']['activities']); + $activity = $finished['recovery']['activities'][0]; + self::assertSame($firstFields['activity_id'], $activity['id']); + self::assertSame(2, $activity['attempt_count']); + self::assertSame('completed', $activity['status']); + self::assertSame($first['recovery']['activities'][0]['retry_policy'], $activity['retry_policy']); + self::assertSame( + $first['recovery']['activities'][0]['schedule_to_close_deadline_at'], + $activity['schedule_to_close_deadline_at'] + ); + self::assertCount(2, $finished['recovery']['attempts']); + $attempts = $finished['recovery']['attempts']; + self::assertSame($firstFields['activity_attempt_id'], $attempts[0]['id']); + self::assertSame('failed', $attempts[0]['status']); + self::assertSame('completed', $attempts[1]['status']); + self::assertSame(2, $attempts[1]['number']); + self::assertNotSame($attempts[0]['id'], $attempts[1]['id']); + self::assertGreaterThan( + 250, + $this->timestamp($first['recovery']['attempts'][0]['lease_expires_at']) + - $this->timestamp($first['recovery']['attempts'][0]['started_at']) + ); + $spans = []; + foreach ($workers as $worker) { + self::assertSame([], $worker['outside_context']); + self::assertTrue($worker['scope_restored']); + foreach ($worker['execution_spans'] as $span) { + if ($span['name'] === 'durable.activity.attempt') { + $spans[] = $span; + } + } + } + self::assertCount($tracing ? 1 : 0, $spans); + if ($tracing) { + self::assertSame($firstFields['activity_id'], $spans[0]['fields']['activity_id']); + self::assertSame($attempts[1]['id'], $spans[0]['fields']['activity_attempt_id']); + self::assertSame($firstFields['trace_id'], $spans[0]['trace_id']); + self::assertSame('00f067aa0ba902b7', $spans[0]['parent_id']); + self::assertNotSame($firstFields['span_id'], $spans[0]['span_id']); + } + self::assertSame( + $first['recovery']['history_hashes'], + array_slice($finished['recovery']['history_hashes'], 0, count($first['recovery']['history_hashes'])) + ); + self::assertSame(1, array_count_values($finished['recovery']['history_types'])['ActivityRetryScheduled']); + self::assertSame(1, array_count_values($finished['recovery']['history_types'])['ActivityCompleted']); + self::assertSame(1, array_count_values($finished['recovery']['history_types'])['WorkflowCompleted']); + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_values(array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'callback' + )); + self::assertCount(2, $callbacks); + if ($tracing) { + self::assertSame($firstFields['activity_attempt_id'], $callbacks[0]['context']['activity_attempt_id']); + self::assertSame($attempts[1]['id'], $callbacks[1]['context']['activity_attempt_id']); + self::assertSame($firstFields['trace_id'], $callbacks[1]['context']['trace_id']); + self::assertSame($spans[0]['span_id'], $callbacks[1]['context']['span_id']); + } else { + foreach ($callbacks as $callback) { + self::assertArrayNotHasKey('workflow_run_id', $callback['context']); + self::assertArrayNotHasKey('trace_id', $callback['context']); + } + } + } finally { + $crashed?->stop(0, 9); + (new Filesystem())->deleteDirectory($directory); + } + } + + private function worker(string $directory, string $mode): array + { + $process = $this->process($directory, $mode); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + } + + private function process(string $directory, string $mode, bool $crash = false): Process + { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_RECOVERY' => 'true', + 'DW_TEST_TRACING_ENABLED' => $this->tracing ? 'true' : 'false', + 'DW_TEST_TRACING_CRASH_FIRST_ATTEMPT' => $crash ? 'true' : 'false', + ]); + $process->setTimeout(15); + + return $process; + } + + private function timestamp(string $value): float + { + return (float) (new DateTimeImmutable($value, new DateTimeZone('UTC')))->format('U.u'); + } +} diff --git a/tests/Unit/V2/ReplayRegressionCorpusTest.php b/tests/Unit/V2/ReplayRegressionCorpusTest.php index c282af76f..27b15eca6 100644 --- a/tests/Unit/V2/ReplayRegressionCorpusTest.php +++ b/tests/Unit/V2/ReplayRegressionCorpusTest.php @@ -7,9 +7,15 @@ use Illuminate\Config\Repository; use Illuminate\Container\Container; use Illuminate\Foundation\Application; +use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\Context\Context; +use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; +use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; +use OpenTelemetry\SDK\Trace\TracerProvider; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\Fixtures\V2\TestReplayMapOrderWorkflow; +use Tests\Fixtures\V2\TestTracingReplayWorkflow; use Throwable; use Workflow\Serializers\Serializer; use Workflow\V2\Enums\HistoryEventType; @@ -18,6 +24,7 @@ use Workflow\V2\Models\WorkflowServiceCall; use Workflow\V2\Support\ActivitySnapshot; use Workflow\V2\Support\CooperativeCancellationDelivery; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\HistoryEventPayloadContract; use Workflow\V2\Support\ServiceCallCancellationOrigin; use Workflow\V2\Support\WorkflowFiberRunner; @@ -112,6 +119,12 @@ public function testFixtureExecutesThroughColdReplayRunner(array $fixture): void sprintf('Replay fixture workflow [%s] must be an autoloadable V2 workflow.', $workflowClass), ); + if ($fixture['id'] === 'workflow-fiber-observability-context') { + $this->assertWorkflowFiberObservabilityContext($fixture); + + return; + } + if (isset($fixture['expected_failure'])) { try { WorkflowFiberRunner::forClass( @@ -368,6 +381,55 @@ public static function replayRegressionFixtures(): array return $fixtures; } + private function assertWorkflowFiberObservabilityContext(array $fixture): void + { + $exporter = new InMemoryExporter(); + $provider = new TracerProvider(new SimpleSpanProcessor($exporter)); + $scope = Configurator::create()->withTracerProvider($provider)->activate(); + $initial = Context::getCurrent(); + TestTracingReplayWorkflow::$contexts = []; + $runner = null; + $fields = []; + try { + $step = ExecutionTracing::workflow([ + 'workflow_run_id' => 'replay-origin', + 'namespace' => '0', + 'task_id' => 'original-task', + ], null, static function () use ($fixture, &$runner, &$fields): WorkflowStep { + $fields = ExecutionTracing::current(); + $runner = WorkflowFiberRunner::forClass( + $fixture['workflow']['type'], + 'replay-origin', + 'replay-origin', + $fixture['workflow']['arguments'], + $fixture['workflow']['payload_codec'], + ); + + return $runner->step(); + }, enabled: true); + $this->assertStepMatches($fixture['command_sequence'][0], $step, 'Original context first step'); + $this->assertSame($initial, Context::getCurrent()); + $step = ExecutionTracing::workflow([ + 'workflow_run_id' => 'unrelated-run', + 'task_id' => 'unrelated-task', + ], null, static fn (): WorkflowStep => $runner->step( + $fixture['command_sequence'][0]['resume_with'] + ), enabled: true); + $this->assertStepMatches($fixture['expected'], $step, 'Original context resumed step'); + $this->assertSame([$fields, $fields], TestTracingReplayWorkflow::$contexts); + $this->assertSame($initial, Context::getCurrent()); + $this->assertSame([], ExecutionTracing::current()); + // Replaying an activity outcome never executes or traces that activity again. + $this->assertCount(2, $exporter->getSpans()); + foreach ($exporter->getSpans() as $span) { + $this->assertSame('durable.workflow.task', $span->getName()); + } + } finally { + $scope->detach(); + $provider->shutdown(); + } + } + /** * @param array $fixture */ diff --git a/tests/Unit/V2/ScheduleExecutionTracingTest.php b/tests/Unit/V2/ScheduleExecutionTracingTest.php new file mode 100644 index 000000000..61b267d98 --- /dev/null +++ b/tests/Unit/V2/ScheduleExecutionTracingTest.php @@ -0,0 +1,551 @@ + [true], + 'disabled buffer control' => [false], + ]; + } + + #[DataProvider('bufferModes')] + public function testBufferedCallerLinksOnlyItsCommittedColdDrain(bool $enabled): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-buffer-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::bufferWorker($directory, $mode, $enabled); + try { + $original = $worker('admit'); + $buffered = $worker('buffer'); + self::assertSame('buffered', $buffered['schedule']['buffer_result']); + self::assertCount(1, $buffered['schedule']['buffered_actions']); + $entry = $buffered['schedule']['buffered_actions'][0]; + if ($enabled) { + self::assertSame( + '00-cccccccccccccccccccccccccccccccc-dddddddddddddddd-01', + $entry['observability_context']['carrier']['traceparent'] ?? null + ); + self::assertSame('approved=buffer-only', $entry['observability_context']['carrier']['baggage']); + } else { + self::assertArrayNotHasKey('observability_context', $entry); + } + unset($entry['observability_context']); + self::assertSame([$entry], $buffered['schedule']['public_buffered_actions']); + + $full = $worker('buffer-full'); + self::assertSame('buffer_full', $full['schedule']['buffer_result']); + self::assertSame($buffered['schedule']['buffered_actions'], $full['schedule']['buffered_actions']); + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $completed = $worker('inspect'); + self::assertSame('completed', $completed['status']); + self::assertSame($original['run_id'], $completed['run_id']); + self::assertSame($original['execution_deadline'], $completed['execution_deadline']); + self::assertSame($original['run_deadline'], $completed['run_deadline']); + + $drained = $worker('drain'); + self::assertNotSame($original['run_id'], $drained['run_id']); + self::assertNull($drained['schedule']['buffered_actions']); + self::assertArrayNotHasKey('observability_context', $drained['schedule']['drain_results'][0]); + if ($enabled) { + $drainedCarrier = json_decode($drained['carrier'], true, flags: JSON_THROW_ON_ERROR); + self::assertArrayNotHasKey('baggage', $drainedCarrier['carrier']); + } + $triggers = array_values(array_filter( + $drained['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $triggers); + if ($enabled) { + self::assertSame('11111111111111111111111111111111', $triggers[0]['trace_id']); + self::assertSame('2222222222222222', $triggers[0]['parent_id']); + self::assertSame([[ + 'trace_id' => 'cccccccccccccccccccccccccccccccc', + 'span_id' => 'dddddddddddddddd', + 'fields' => [ + 'durable.link.type' => 'buffered_schedule', + ], + ]], $triggers[0]['links']); + } + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame($drained['carrier'], $finished['carrier']); + self::assertSame($drained['execution_deadline'], $finished['execution_deadline']); + self::assertSame($drained['run_deadline'], $finished['run_deadline']); + self::assertSame(2, $finished['schedule']['fires_count']); + self::assertCount(6, $finished['schedule']['tasks']); + foreach ($finished['schedule']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + self::assertSame(0, $task['repair_count']); + } + foreach ([$original, $buffered, $full, $completed, $drained, $finished] as $checkpoint) { + foreach ($checkpoint['schedule']['capture_transaction_levels'] as $level) { + self::assertSame(0, $level); + } + foreach ($checkpoint['schedule']['boundaries'] as $boundary) { + self::assertSame(0, $boundary['transaction_level']); + } + } + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + public static function missingBufferMetadata(): array + { + return [ + 'legacy buffer' => ['legacy'], + 'malformed optional carrier' => ['malformed'], + ]; + } + + #[DataProvider('missingBufferMetadata')] + public function testLegacyOrMalformedBufferKeepsBusinessStateWithoutInventingACaller(string $metadata): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-buffer-metadata-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::bufferWorker($directory, $mode, true, $metadata); + try { + $worker('admit'); + $buffered = $worker('buffer'); + $changed = $worker('buffer-metadata'); + self::assertSame( + $buffered['schedule']['public_buffered_actions'], + $changed['schedule']['public_buffered_actions'] + ); + self::assertSame($buffered['schedule']['history_hashes'], $changed['schedule']['history_hashes']); + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $drained = $worker('drain'); + $triggers = array_values(array_filter( + $drained['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount(1, $triggers); + self::assertSame('2222222222222222', $triggers[0]['parent_id']); + self::assertSame([], $triggers[0]['links']); + self::assertNull($drained['schedule']['buffered_actions']); + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame($drained['execution_deadline'], $finished['execution_deadline']); + self::assertSame($drained['run_deadline'], $finished['run_deadline']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['failed_jobs']); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + public function testRolledBackDrainRetainsItsOriginalBufferedCallerUntilActualAdmission(): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-buffer-rollback-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::bufferWorker($directory, $mode, true); + try { + $worker('admit'); + $buffered = $worker('buffer'); + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $before = $worker('inspect'); + $failed = $worker('drain-fail'); + self::assertSame([[ + 'schedule_id' => 'cold-schedule', + 'instance_id' => null, + 'error' => 'deliberate buffered admission rollback', + ]], $failed['schedule']['drain_results']); + self::assertSame(1, $failed['schedule']['failures_count']); + self::assertSame(1, $failed['schedule']['fires_count']); + self::assertSame(1, $failed['schedule']['run_count']); + self::assertSame($buffered['schedule']['buffered_actions'], $failed['schedule']['buffered_actions']); + foreach (['run_id', 'execution_deadline', 'run_deadline', 'carrier'] as $field) { + self::assertSame($before[$field], $failed[$field]); + } + self::assertSame($before['schedule']['history_hashes'], $failed['schedule']['history_hashes']); + self::assertSame($before['schedule']['tasks'], $failed['schedule']['tasks']); + $triggers = array_values(array_filter( + $failed['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount(1, $triggers); + self::assertSame([], $triggers[0]['links']); + self::assertSame(StatusCode::STATUS_ERROR, $triggers[0]['status_code']); + + $drained = $worker('drain'); + $triggers = array_values(array_filter( + $drained['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertSame('cccccccccccccccccccccccccccccccc', $triggers[0]['links'][0]['trace_id']); + self::assertNull($drained['schedule']['buffered_actions']); + self::assertSame(2, $drained['schedule']['run_count']); + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['failed_jobs']); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + public static function modes(): array + { + return [ + 'manual traced' => ['manual', true], + 'manual disabled' => ['manual', false], + 'periodic traced' => ['scheduled', true], + 'periodic disabled' => ['scheduled', false], + 'backfill traced' => ['backfill', true], + 'backfill disabled' => ['backfill', false], + ]; + } + + #[DataProvider('modes')] + public function testActualScheduleAdmissionAndColdWorkersKeepTheirOriginalCaller(string $kind, bool $enabled): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-schedule-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::scheduleWorker($directory, $mode, $kind, $enabled); + try { + $admitted = $worker('admit'); + $workers = [$worker('work'), $worker('work'), $worker('work')]; + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $finished[$field]); + } + self::assertSame(1, $finished['schedule']['fires_count']); + self::assertSame($finished['schedule']['instance_id'], $finished['schedule']['latest_instance_id']); + self::assertSame(1, array_count_values($finished['schedule']['history_types'])['ScheduleTriggered']); + self::assertSame(1, array_count_values($finished['schedule']['history_types'])['ActivityCompleted']); + self::assertSame(1, array_count_values($finished['schedule']['history_types'])['WorkflowCompleted']); + self::assertCount(3, $finished['schedule']['tasks']); + foreach ($finished['schedule']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + self::assertSame(0, $task['repair_count']); + } + foreach ([$admitted, ...$workers] as $checkpoint) { + self::assertSame($checkpoint['schedule']['history_hashes'], array_slice( + $finished['schedule']['history_hashes'], + 0, + count($checkpoint['schedule']['history_hashes']) + )); + foreach ($checkpoint['schedule']['capture_transaction_levels'] as $level) { + self::assertSame(0, $level); + } + foreach ($checkpoint['schedule']['boundaries'] as $boundary) { + self::assertSame(0, $boundary['transaction_level']); + } + } + $triggers = array_values(array_filter( + $admitted['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $triggers); + if ($enabled) { + self::assertSame('00f067aa0ba902b7', $triggers[0]['parent_id']); + self::assertSame($kind, $triggers[0]['fields']['durable.schedule.trigger_kind']); + self::assertSame('triggered', $triggers[0]['fields']['durable.schedule.outcome']); + if ($kind === 'backfill') { + self::assertSame( + $admitted['schedule']['instance_id'], + $triggers[0]['fields']['workflow_instance_id'] + ); + self::assertArrayNotHasKey('workflow_run_id', $triggers[0]['fields']); + self::assertCount(1, $admitted['schedule']['backfill_results']); + self::assertSame( + $admitted['schedule']['instance_id'], + $admitted['schedule']['backfill_results'][0]['instance_id'] + ); + } else { + self::assertSame($admitted['run_id'], $triggers[0]['fields']['workflow_run_id']); + } + } + $processing = array_values(array_filter( + array_merge(...array_column($workers, 'spans')), + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_CONSUMER + )); + self::assertCount($enabled ? 3 : 0, $processing); + foreach ($processing as $span) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + } + self::assertCount(3, array_unique(array_column($workers, 'pid'))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + #[DataProvider('bufferModes')] + public function testBackfillDuplicateAndRollbackKeepTheOriginalOccurrenceAndCaller(bool $enabled): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-backfill-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::scheduleWorker($directory, $mode, 'backfill', $enabled); + try { + $admitted = $worker('admit'); + if (! $enabled) { + self::assertNull($admitted['carrier']); + } + $duplicate = $worker('backfill-duplicate'); + self::assertNull($duplicate['schedule']['backfill_results'][0]['instance_id']); + self::assertSame( + $admitted['schedule']['backfill_results'][0]['cron_time'], + $duplicate['schedule']['backfill_results'][0]['cron_time'] + ); + self::assertSame(1, $duplicate['schedule']['run_count']); + self::assertSame(1, $duplicate['schedule']['fires_count']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $duplicate[$field]); + } + self::assertSame($admitted['schedule']['history_hashes'], $duplicate['schedule']['history_hashes']); + self::assertSame($admitted['schedule']['tasks'], $duplicate['schedule']['tasks']); + $duplicateSpans = array_values(array_filter( + $duplicate['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $duplicateSpans); + if ($enabled) { + self::assertSame('no_run', $duplicateSpans[0]['fields']['durable.schedule.outcome']); + self::assertArrayNotHasKey('workflow_instance_id', $duplicateSpans[0]['fields']); + self::assertArrayNotHasKey('workflow_run_id', $duplicateSpans[0]['fields']); + } + for ($index = 0; $index < 3; ++$index) { + $worker('work'); + } + $original = $worker('inspect'); + self::assertSame('completed', $original['status']); + $failed = $worker('backfill-fail'); + self::assertSame( + 'deliberate backfill admission rollback', + $failed['schedule']['backfill_results'][0]['error'] + ); + self::assertNull($failed['schedule']['backfill_results'][0]['instance_id']); + self::assertSame(1, $failed['schedule']['run_count']); + self::assertSame(1, $failed['schedule']['fires_count']); + self::assertSame(1, $failed['schedule']['failures_count']); + self::assertSame(0, $failed['jobs']); + self::assertSame($original['schedule']['tasks'], $failed['schedule']['tasks']); + self::assertSame($original['schedule']['history_hashes'], $failed['schedule']['history_hashes']); + $failedSpans = array_values(array_filter( + $failed['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $failedSpans); + if ($enabled) { + self::assertSame(StatusCode::STATUS_ERROR, $failedSpans[0]['status_code']); + self::assertSame(\RuntimeException::class, $failedSpans[0]['fields']['error.type']); + self::assertArrayNotHasKey('workflow_instance_id', $failedSpans[0]['fields']); + self::assertArrayNotHasKey('workflow_run_id', $failedSpans[0]['fields']); + } + $next = $worker('backfill-next'); + self::assertNotSame($original['run_id'], $next['run_id']); + self::assertSame( + $failed['schedule']['backfill_results'][0]['cron_time'], + $next['schedule']['backfill_results'][0]['cron_time'] + ); + self::assertSame(2, $next['schedule']['fires_count']); + self::assertSame(2, $next['schedule']['run_count']); + $workers = [$worker('work'), $worker('work'), $worker('work')]; + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($next[$field], $finished[$field]); + } + self::assertSame($next['schedule']['history_hashes'], array_slice( + $finished['schedule']['history_hashes'], + 0, + count($next['schedule']['history_hashes']) + )); + foreach (['ScheduleTriggered', 'ActivityCompleted', 'WorkflowCompleted'] as $eventType) { + self::assertSame(1, array_count_values($finished['schedule']['history_types'])[$eventType]); + } + foreach ($finished['schedule']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + self::assertSame(0, $task['repair_count']); + } + self::assertCount(6, $finished['schedule']['tasks']); + $processing = array_values(array_filter( + array_merge(...array_column($workers, 'spans')), + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_CONSUMER + )); + self::assertCount($enabled ? 3 : 0, $processing); + foreach ($processing as $span) { + self::assertSame('11111111111111111111111111111111', $span['trace_id']); + self::assertSame('2222222222222222', $span['parent_id']); + self::assertSame($next['run_id'], $span['fields']['workflow_run_id']); + } + $retained = $worker('inspect-first'); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline', 'status', 'output'] as $field) { + self::assertSame($original[$field], $retained[$field]); + } + self::assertSame($original['schedule']['history_hashes'], $retained['schedule']['history_hashes']); + foreach ([$duplicate, $failed, $next, ...$workers] as $checkpoint) { + foreach ($checkpoint['schedule']['capture_transaction_levels'] as $level) { + self::assertSame(0, $level); + } + foreach ($checkpoint['schedule']['boundaries'] as $boundary) { + self::assertSame(0, $boundary['transaction_level']); + } + } + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + public function testAllBufferedCallersRemainPairedWithTheirOwnColdDrains(): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-buffer-all-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::bufferWorker($directory, $mode, true, 'valid', 'buffer_all'); + try { + $original = $worker('admit'); + $first = $worker('buffer'); + $second = $worker('buffer-next'); + self::assertSame('buffered', $first['schedule']['buffer_result']); + self::assertSame('buffered', $second['schedule']['buffer_result']); + self::assertCount(2, $second['schedule']['buffered_actions']); + self::assertSame($first['schedule']['buffered_actions'][0], $second['schedule']['buffered_actions'][0]); + $runIds = [$original['run_id']]; + foreach (['cccccccccccccccccccccccccccccccc', 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'] as $caller) { + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $before = $worker('inspect'); + self::assertSame('completed', $before['status']); + $drained = $worker('drain'); + $runIds[] = $drained['run_id']; + $triggers = array_values(array_filter( + $drained['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount(1, $triggers); + self::assertCount(1, $triggers[0]['links']); + self::assertSame($caller, $triggers[0]['links'][0]['trace_id']); + self::assertSame('2222222222222222', $triggers[0]['parent_id']); + if ($caller === 'cccccccccccccccccccccccccccccccc') { + self::assertSame( + [$second['schedule']['buffered_actions'][1]], + $drained['schedule']['buffered_actions'] + ); + } else { + self::assertNull($drained['schedule']['buffered_actions']); + } + foreach ($drained['schedule']['boundaries'] as $boundary) { + self::assertSame(0, $boundary['transaction_level']); + } + } + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame($drained['execution_deadline'], $finished['execution_deadline']); + self::assertSame($drained['run_deadline'], $finished['run_deadline']); + self::assertSame(3, count(array_unique($runIds))); + self::assertSame(3, $finished['schedule']['fires_count']); + self::assertSame(3, $finished['schedule']['run_count']); + self::assertCount(9, $finished['schedule']['tasks']); + foreach ($finished['schedule']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + self::assertSame(0, $task['repair_count']); + } + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function bufferWorker( + string $directory, + string $mode, + bool $enabled, + string $metadata = 'valid', + string $policy = 'buffer_one' + ): array { + return self::scheduleWorker($directory, $mode, 'buffered', $enabled, [ + 'DW_TEST_TRACING_BUFFER_METADATA' => $metadata, + 'DW_TEST_TRACING_BUFFER_POLICY' => $policy, + ]); + } + + private static function scheduleWorker( + string $directory, + string $mode, + string $kind, + bool $enabled, + array $environment = [] + ): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_SCHEDULE' => $kind, + 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', + ...$environment, + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + } +} diff --git a/tests/Unit/V2/SignalLinkExecutionTracingTest.php b/tests/Unit/V2/SignalLinkExecutionTracingTest.php new file mode 100644 index 000000000..5ebcfffa1 --- /dev/null +++ b/tests/Unit/V2/SignalLinkExecutionTracingTest.php @@ -0,0 +1,185 @@ + [true, true], + 'disabled control' => [false, false], + 'legacy admission' => [false, true], + ]; + } + + #[DataProvider('modes')] + public function testIndependentSignalCallersLinkOnlyTheirActualApplicationSegments( + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-signal-links-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode, string $step = 'first') use ( + $directory, + $admissionEnabled, + $workerEnabled + ): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_SIGNAL_LINKS' => 'true', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + 'DW_TEST_TRACING_SIGNAL_STEP' => $step, + 'DW_TEST_TRACING_SIGNAL_CALLER' => $step === 'first' ? self::FIRST_CALLER : self::SECOND_CALLER, + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame( + $admissionEnabled ? array_fill( + 0, + 1 + count($admitted['spans']) - count($admitted['execution_spans']), + 0 + ) : [], + $admitted['signals']['capture_transaction_levels'] + ); + $early = $worker('signal'); + self::assertSame( + $workerEnabled ? array_fill(0, 1 + count($early['spans']) - count($early['execution_spans']), 0) : [], + $early['signals']['capture_transaction_levels'] + ); + self::assertSame($workerEnabled, $early['signals']['commands'][0]['carrier'] !== null); + $waiting = $worker('work'); + self::assertSame( + 'waiting', + $waiting['status'], + json_encode(array_column($waiting['signals']['history_payloads'], 'message'), JSON_THROW_ON_ERROR) + ); + self::assertSame(0, $waiting['jobs']); + $late = $worker('signal', 'second'); + self::assertSame( + $workerEnabled ? array_fill(0, 1 + count($late['spans']) - count($late['execution_spans']), 0) : [], + $late['signals']['capture_transaction_levels'] + ); + $workers = [$waiting]; + for ($remaining = 6; $remaining > 0 && $worker('inspect')['jobs'] > 0; --$remaining) { + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([ + 'first' => [9007199254740993, null, '雪'], + 'second' => [false, null, 'é'], + 'activity' => [9007199254740993, null, '雪'], + ], $finished['output']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + foreach ([$admitted, $early, $waiting, $late] as $prefix) { + self::assertSame($prefix['signals']['history_hashes'], array_slice( + $finished['signals']['history_hashes'], + 0, + count($prefix['signals']['history_hashes']) + )); + } + self::assertSame($early['signals']['commands'][0], $finished['signals']['commands'][0]); + self::assertSame($late['signals']['commands'], $finished['signals']['commands']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertCount(4, $finished['signals']['tasks']); + foreach ($finished['signals']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + } + $spans = array_merge(...array_column($workers, 'execution_spans')); + self::assertCount($workerEnabled ? 4 : 0, $spans); + foreach ($spans as $span) { + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + } + if ($workerEnabled) { + foreach ([self::FIRST_CALLER, self::SECOND_CALLER] as $index => $caller) { + $link = $spans[$index]['links']; + self::assertCount(1, $link); + self::assertSame(explode('-', $caller)[1], $link[0]['trace_id']); + self::assertSame(explode('-', $caller)[2], $link[0]['span_id']); + self::assertSame( + $finished['signals']['commands'][$index]['id'], + $link[0]['fields']['workflow_command_id'] + ); + self::assertSame('signal', $link[0]['fields']['command_type']); + self::assertSame($caller, json_decode( + $finished['signals']['commands'][$index]['carrier'], + true, + flags: JSON_THROW_ON_ERROR + )['carrier']['traceparent']); + } + self::assertSame([], $spans[2]['links']); + self::assertSame([], $spans[3]['links']); + } else { + foreach ($finished['signals']['commands'] as $command) { + self::assertNull($command['carrier']); + } + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_filter($records, static fn (array $record): bool => $record['message'] === 'callback'); + self::assertCount(1, $callbacks); + foreach ($records as $record) { + if ($record['message'] === 'signal applied') { + self::assertSame($admissionEnabled ? 'origin' : null, $record['context']['approved_baggage']); + } + if ($record['message'] === 'worker outside' || ! $workerEnabled) { + self::assertArrayNotHasKey('trace_id', $record['context']); + self::assertArrayNotHasKey('workflow_run_id', $record['context']); + } + } + self::assertStringNotContainsString('observability_context', json_encode( + $finished['signals']['history_payloads'], + JSON_THROW_ON_ERROR + )); + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/SignalStartExecutionTracingTest.php b/tests/Unit/V2/SignalStartExecutionTracingTest.php new file mode 100644 index 000000000..cb6f00656 --- /dev/null +++ b/tests/Unit/V2/SignalStartExecutionTracingTest.php @@ -0,0 +1,226 @@ + [true, true, false], + 'disabled control' => [false, false, false], + 'legacy admission, enabled later' => [false, true, false], + 'no application parent' => [true, true, true], + ]; + } + + #[DataProvider('modes')] + public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndColdWorkers( + bool $admissionEnabled, + bool $workerEnabled, + bool $withoutParent, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-signal-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ( + $directory, + $admissionEnabled, + $workerEnabled, + $withoutParent + ): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_SIGNAL_START' => 'true', + 'DW_TEST_TRACING_SIGNAL_START_ADMISSION' => 'true', + 'DW_TEST_TRACING_ADMISSION_NO_PARENT' => $withoutParent ? 'true' : 'false', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + self::assertTrue($result['admission']['context_preserved']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame('started_new', $admitted['signal_start_outcome']); + $originalHasParent = $admissionEnabled && ! $withoutParent; + self::assertSame($originalHasParent, $admitted['carrier'] !== null); + self::assertSame($originalHasParent, $admitted['signal_commands'][0]['carrier'] !== null); + self::assertSame( + $admissionEnabled ? array_fill( + 0, + 1 + count($admitted['spans']) - count($admitted['execution_spans']), + 0 + ) : [], + $admitted['capture_transaction_levels'] + ); + $existing = $worker('duplicate'); + self::assertSame('returned_existing_active', $existing['signal_start_outcome']); + self::assertLessThanOrEqual( + $workerEnabled ? 1 + count($existing['spans']) - count($existing['execution_spans']) : 0, + count($existing['capture_transaction_levels']) + ); + foreach ($existing['capture_transaction_levels'] as $level) { + self::assertSame(0, $level); + } + self::assertSame(2, $existing['signal_count']); + self::assertSame($admitted['signal_commands'][0], $existing['signal_commands'][0]); + self::assertSame($workerEnabled && ! $withoutParent, $existing['signal_commands'][1]['carrier'] !== null); + self::assertSame( + $admitted['history_hashes'], + array_slice($existing['history_hashes'], 0, count($admitted['history_hashes'])) + ); + $rejected = $worker('reject-signal-start'); + self::assertSame('rejected', $rejected['signal_start_result']['status']); + self::assertSame('unknown_signal', $rejected['signal_start_result']['rejection_reason']); + self::assertNull($rejected['signal_start_result']['start_command_id']); + self::assertSame(2, $rejected['signal_count']); + self::assertSame($existing['signal_commands'], array_slice($rejected['signal_commands'], 0, 2)); + self::assertCount(3, $rejected['signal_commands']); + self::assertNull($rejected['signal_commands'][2]['carrier']); + self::assertSame($existing['history_hashes'], array_slice( + $rejected['history_hashes'], + 0, + count($existing['history_hashes']) + )); + $workers = []; + for ($remaining = 6; $remaining > 0; --$remaining) { + $state = $worker('inspect'); + if ($state['jobs'] === 0) { + break; + } + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([ + 'signal' => [9007199254740993, null, '雪'], + 'activity' => [9007199254740993, null, '雪'], + ], $finished['output']); + self::assertSame(1, $finished['run_count']); + self::assertSame(1, $finished['completion_count']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame($rejected['signal_commands'], $finished['signal_commands']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $existing[$key]); + self::assertSame($admitted[$key], $rejected[$key]); + self::assertSame($admitted[$key], $finished[$key]); + } + self::assertSame( + $existing['history_hashes'], + array_slice($finished['history_hashes'], 0, count($existing['history_hashes'])) + ); + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + $spans = array_merge(...array_column($workers, 'execution_spans')); + self::assertCount($workerEnabled ? 3 : 0, $spans); + foreach ($spans as $span) { + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + if ($originalHasParent) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + } + self::assertStringNotContainsString( + 'observability_context', + json_encode($finished['history_payloads'], JSON_THROW_ON_ERROR) + ); + foreach ([$admitted, $existing, $rejected] as $index => $attempt) { + $enabled = $index === 0 ? $admissionEnabled : $workerEnabled; + self::assertSame( + $enabled ? array_fill(0, 1 + count($attempt['spans']) - count($attempt['execution_spans']), 0) : [], + $attempt['capture_transaction_levels'] + ); + self::assertCount($enabled ? 1 : 0, $attempt['execution_spans']); + if (! $enabled) { + self::assertSame([], $attempt['admission']['boundaries']); + continue; + } + self::assertSame([ + [ + 'event' => 'start', + 'transaction_level' => 0, + ], + [ + 'event' => 'end', + 'transaction_level' => 0, + ], + ], $attempt['admission']['boundaries']); + $span = $attempt['execution_spans'][0]; + $receipt = $attempt['signal_start_result']; + self::assertSame('durable.workflow.admission', $span['name']); + self::assertSame(SpanKind::KIND_INTERNAL, $span['kind']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + self::assertSame('0', $span['fields']['namespace']); + self::assertSame($receipt['command_id'], $span['fields']['workflow_command_id']); + self::assertSame('signal', $span['fields']['durable.command.type']); + self::assertSame($receipt['status'], $span['fields']['durable.command.status']); + self::assertSame($receipt['outcome'], $span['fields']['durable.command.outcome']); + if ($index === 2) { + self::assertSame('unknown_signal', $span['fields']['durable.command.rejection_reason']); + foreach (['command_id', 'status', 'outcome'] as $field) { + self::assertArrayNotHasKey('durable.start.' . $field, $span['fields']); + } + } else { + foreach (['command_id', 'status', 'outcome'] as $field) { + self::assertSame($receipt['start_' . $field], $span['fields']['durable.start.' . $field]); + } + } + self::assertSame($withoutParent ? '0000000000000000' + : ($index === 0 ? '00f067aa0ba902b7' : 'bbbbbbbbbbbbbbbb'), $span['parent_id']); + if (! $withoutParent) { + self::assertSame($index === 0 ? '4bf92f3577b34da6a3ce929d0e0e4736' + : 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $span['trace_id']); + } + self::assertStringNotContainsString( + 'refused-admission-private', + json_encode($span, JSON_THROW_ON_ERROR) + ); + foreach ($spans as $processing) { + self::assertNotSame($span['span_id'], $processing['parent_id']); + foreach ($processing['links'] as $link) { + self::assertNotSame($span['span_id'], $link['span_id']); + } + } + } + foreach ([$admitted['signal_commands'][0], $existing['signal_commands'][1]] as $index => $command) { + if ($command['carrier'] !== null) { + $carrier = json_decode($command['carrier'], true, flags: JSON_THROW_ON_ERROR)['carrier']; + self::assertSame($index === 0 + ? '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + : '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', $carrier['traceparent']); + } + } + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/TaskOutcomeCarrierTest.php b/tests/Unit/V2/TaskOutcomeCarrierTest.php new file mode 100644 index 000000000..c8db9173e --- /dev/null +++ b/tests/Unit/V2/TaskOutcomeCarrierTest.php @@ -0,0 +1,385 @@ +getAttributes(); + unset($attributes['observability_context']); + $claim->setRawAttributes($attributes, true); + self::assertSame([], TaskOutcomeCarrier::forRun(new WorkflowRun(), $claim)); + self::assertNull(TaskOutcomeCarrier::capture(Span::getInvalid())); + self::assertSame($attributes, $claim->getRawOriginal()); + } + + public function testOversizedAndInvalidStoredOutcomesAreIgnoredWithoutRewriting(): void + { + $oversized = str_repeat('x', 4097); + $task = self::task($oversized); + self::assertNull(TaskOutcomeCarrier::fromTask($task)); + self::assertSame($oversized, $task->getAttributes()['observability_context']); + $task = self::task(); + TaskOutcomeCarrier::append($task, [self::entry()]); + $invalid = TaskOutcomeCarrier::fromTask($task); + $invalid['outcomes'][0]['sequence'] = 0; + $stored = json_encode($invalid, JSON_THROW_ON_ERROR); + $task->setRawAttributes(array_replace($task->getAttributes(), [ + 'observability_context' => $stored, + ]), true); + self::assertNull(TaskOutcomeCarrier::fromTask($task)); + self::assertSame($stored, $task->getRawOriginal('observability_context')); + } + + public function testDuplicateKeepsTheFirstStoredBytesAndContext(): void + { + $task = self::task(); + TaskOutcomeCarrier::append($task, [self::entry()]); + $stored = json_encode(TaskOutcomeCarrier::fromTask($task), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + $task->setRawAttributes(array_replace($task->getAttributes(), [ + 'observability_context' => $stored, + ]), true); + $other = self::entry(); + $other['context']['carrier']['traceparent'] = '00-11111111111111111111111111111111-2222222222222222-01'; + TaskOutcomeCarrier::append($task, [$other]); + self::assertSame($stored, $task->getAttributes()['observability_context']); + self::assertSame(self::entry(), TaskOutcomeCarrier::fromTask($task)['outcomes'][0]); + self::assertArrayNotHasKey('observability_context', $task->toArray()); + } + + public function testBoundedAppendRetainsCompleteEarlyEntries(): void + { + $task = self::task(); + $entries = []; + for ($sequence = 1; $sequence <= 60; ++$sequence) { + $entries[] = array_replace(self::entry(), [ + 'event_id' => 'event-' . $sequence, + 'sequence' => $sequence, + ]); + } + TaskOutcomeCarrier::append($task, $entries); + $stored = TaskOutcomeCarrier::fromTask($task); + self::assertNotEmpty($stored['outcomes']); + self::assertLessThanOrEqual(32, count($stored['outcomes'])); + self::assertLessThanOrEqual(4096, strlen($task->getAttributes()['observability_context'])); + self::assertSame(array_slice($entries, 0, count($stored['outcomes'])), $stored['outcomes']); + } + + public function testLegacyAndMalformedStorageRemainUnchanged(): void + { + $legacy = self::task(); + $attributes = $legacy->getAttributes(); + unset($attributes['observability_context']); + $legacy->setRawAttributes($attributes, true); + TaskOutcomeCarrier::append($legacy, [self::entry()]); + self::assertSame($attributes, $legacy->getAttributes()); + $bad = self::task('broken{'); + self::assertNull(TaskOutcomeCarrier::fromTask($bad)); + TaskOutcomeCarrier::append($bad, [self::entry()]); + self::assertSame('broken{', $bad->getAttributes()['observability_context']); + } + + public static function invalidEntries(): iterable + { + yield 'missing' => [null]; + foreach ([ + 'event_id' => '', + 'sequence' => '1', + 'event_type' => 'Other', + 'activity_attempt_id' => null, + ] as $key => $value) { + yield $key => [ + array_replace(self::entry(), [ + $key => $value, + ])]; + } + yield 'payload' => [ + self::entry() + [ + 'payload' => 'must-not-store', + ]]; + $baggage = self::entry(); + $baggage['context']['carrier']['baggage'] = 'private=discard'; + yield 'baggage' => [$baggage]; + $invalid = self::entry(); + $invalid['context']['carrier']['traceparent'] = 'invalid'; + yield 'traceparent' => [$invalid]; + yield 'timer identity on activity' => [ + self::entry() + [ + 'timer_id' => 'other', + ]]; + } + + #[DataProvider('invalidEntries')] + public function testInvalidEntriesNeverBecomeStoredOutcomes(mixed $entry): void + { + self::assertNull(TaskOutcomeCarrier::entry($entry)); + $task = self::task(); + TaskOutcomeCarrier::append($task, [$entry]); + self::assertNull($task->getAttributes()['observability_context']); + } + + public function testNamespaceRunAndTaskBindingsCannotBeReinterpreted(): void + { + $task = self::task(); + TaskOutcomeCarrier::append($task, [self::entry()]); + $original = TaskOutcomeCarrier::fromTask($task); + foreach (['namespace', 'run_id', 'task_id', 'schema', 'version'] as $field) { + $wrong = array_replace($original, [ + $field => 'other', + ]); + $task->setRawAttributes(array_replace($task->getAttributes(), [ + 'observability_context' => json_encode($wrong, JSON_THROW_ON_ERROR), + ]), true); + self::assertNull(TaskOutcomeCarrier::fromTask($task)); + } + $event = new WorkflowHistoryEvent(); + $event->setRawAttributes([ + 'id' => 'event-1', + 'sequence' => 1, + 'event_type' => 'ActivityCompleted', + 'payload' => json_encode([ + 'activity_execution_id' => 'activity-1', + 'activity_attempt_id' => 'attempt-1', + ]), + ], true); + self::assertTrue(TaskOutcomeCarrier::matchesEvent(self::entry(), $event)); + self::assertFalse(TaskOutcomeCarrier::matchesEvent(array_replace(self::entry(), [ + 'sequence' => 2, + ]), $event)); + self::assertFalse(TaskOutcomeCarrier::matchesEvent(array_replace(self::entry(), [ + 'activity_attempt_id' => 'other', + ]), $event)); + } + + public function testInspectionNeverExecutesObjectSerialization(): void + { + $object = new class() implements JsonSerializable { + public int $calls = 0; + + public function jsonSerialize(): mixed + { + ++$this->calls; + return 'must-not-execute'; + } + }; + $entry = self::entry(); + $entry['context']['carrier']['traceparent'] = $object; + self::assertNull(TaskOutcomeCarrier::entry($entry)); + self::assertNull(TaskOutcomeCarrier::entry(self::entry() + [ + 'payload' => $object, + ])); + self::assertSame(0, $object->calls); + } + + public static function childProofs(): iterable + { + yield 'canonical proof' => ['valid']; + yield 'different parent run' => ['parent']; + yield 'different original link' => ['link']; + yield 'different physical task' => ['task']; + yield 'different child namespace' => ['namespace']; + yield 'missing child context' => ['missing']; + } + + #[DataProvider('childProofs')] + public function testChildLinkRequiresTheOriginalProofWithoutQueryingOrChangingTheOperation(string $proof): void + { + $exporter = new InMemoryExporter(); + $provider = new TracerProvider(new SimpleSpanProcessor($exporter)); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + try { + $source = $provider->getTracer('fixture') + ->spanBuilder('child completion') + ->startSpan(); + $context = TaskOutcomeCarrier::capture($source); + $source->end(); + $terminal = new WorkflowHistoryEvent(); + $terminal->setRawAttributes([ + 'id' => 'terminal', + 'workflow_run_id' => 'child', + 'workflow_task_id' => 'child-task', + 'event_type' => 'WorkflowCompleted', + 'sequence' => 3, + 'payload' => '{}', + ], true); + $child = new WorkflowRun(); + $child->setConnection('unavailable-observation-source'); + $child->setRawAttributes([ + 'id' => 'child', + 'workflow_instance_id' => 'child-instance', + 'namespace' => '0', + ], true); + $child->setRelation('historyEvents', new Collection([$terminal])); + $resolution = new WorkflowHistoryEvent(); + $resolution->setRawAttributes([ + 'id' => 'resolution', + 'workflow_run_id' => 'parent', + 'event_type' => 'ChildRunCompleted', + 'payload' => json_encode([ + 'workflow_link_id' => $proof === 'link' ? 'other' : 'link', + 'sequence' => 1, + 'child_workflow_run_id' => 'child', + 'child_workflow_instance_id' => 'child-instance', + ], JSON_THROW_ON_ERROR), + ], true); + $entry = [ + 'event_id' => 'terminal', + 'sequence' => 3, + 'event_type' => 'WorkflowCompleted', + 'context' => $context, + 'task_id' => 'child-task', + 'child_run_id' => 'child', + 'child_instance_id' => 'child-instance', + 'child_namespace' => '0', + 'parent_run_id' => 'parent', + 'parent_namespace' => '0', + 'parent_sequence' => 1, + 'link_id' => 'link', + ]; + if ($proof === 'parent') { + $resolution->workflow_run_id = 'other'; + } elseif ($proof === 'task') { + $terminal->workflow_task_id = 'other'; + } elseif ($proof === 'namespace') { + $child->namespace = 'other'; + } + $calls = 0; + $result = ExecutionTracing::workflow([ + 'workflow_run_id' => 'parent', + 'workflow_instance_id' => 'parent-instance', + 'namespace' => '0', + 'task_id' => 'parent-task', + ], null, static function () use ($resolution, $child, &$calls): array { + ++$calls; + ExecutionTracing::linkChildOutcome($resolution, $child); + return [9007199254740993, null, '雪']; + }, enabled: true, loadOutcomes: static fn (): array => $proof === 'missing' ? [] : [ + 'child:child' => $entry, + ]); + self::assertSame([9007199254740993, null, '雪'], $result); + self::assertSame(1, $calls); + self::assertSame([], ExecutionTracing::current()); + $spans = array_values(array_filter($exporter->getSpans(), static fn ($span): bool => + $span->getName() === 'durable.workflow.task')); + self::assertCount(1, $spans); + $links = $spans[0]->getLinks(); + self::assertCount($proof === 'valid' ? 1 : 0, $links); + self::assertSame( + ! in_array($proof, ['valid', 'parent'], true), + $spans[0]->getAttributes()->toArray()['durable.links.outcome_context_unavailable'] ?? false + ); + if ($proof === 'valid') { + self::assertSame($source->getContext()->getTraceId(), $links[0]->getSpanContext()->getTraceId()); + self::assertSame($source->getContext()->getSpanId(), $links[0]->getSpanContext()->getSpanId()); + } + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + + public static function outcomeAvailability(): array + { + return [ + 'prior outcome' => ['prior'], + 'current task' => ['current'], + 'other run' => ['foreign'], + ]; + } + + #[DataProvider('outcomeAvailability')] + public function testMissingPriorContextMarksOnlyItsOwnRecordingRun(string $kind): void + { + $exporter = new InMemoryExporter(); + $provider = new TracerProvider(new SimpleSpanProcessor($exporter)); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + try { + $run = new WorkflowRun(); + $run->setRawAttributes([ + 'id' => 'parent', + 'namespace' => '0', + ], true); + $event = new WorkflowHistoryEvent(); + $event->setRawAttributes([ + 'id' => 'outcome', + 'workflow_run_id' => $kind === 'foreign' ? 'other' : 'parent', + 'workflow_task_id' => $kind === 'current' ? 'processing' : 'prior', + 'event_type' => 'ActivityCompleted', + 'sequence' => 3, + 'payload' => '{}', + ], true); + $calls = 0; + $result = ExecutionTracing::workflow([ + 'workflow_run_id' => 'parent', + 'namespace' => '0', + 'task_id' => 'processing', + ], null, static function () use ($run, $event, &$calls): array { + ++$calls; + ExecutionTracing::linkOutcome($run, $event); + return [9007199254740993, null, '雪']; + }, enabled: true); + self::assertSame([9007199254740993, null, '雪'], $result); + self::assertSame(1, $calls); + self::assertSame([], ExecutionTracing::current()); + $spans = $exporter->getSpans(); + self::assertCount(1, $spans); + self::assertSame( + $kind === 'prior', + $spans[0]->getAttributes()->toArray()['durable.links.outcome_context_unavailable'] ?? false + ); + self::assertSame([], $spans[0]->getLinks()); + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + + private static function task(mixed $context = null): WorkflowTask + { + $task = new WorkflowTask(); + $task->setRawAttributes([ + 'id' => 'task-1', + 'workflow_run_id' => 'run-1', + 'namespace' => '0', + 'observability_context' => $context, + ], true); + return $task; + } + + private static function entry(): array + { + return [ + 'event_id' => 'event-1', + 'sequence' => 1, + 'event_type' => 'ActivityCompleted', + 'activity_id' => 'activity-1', + 'activity_attempt_id' => 'attempt-1', + 'context' => [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => [ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + ], + ], + ]; + } +} diff --git a/tests/Unit/V2/TaskTraceCarrierTest.php b/tests/Unit/V2/TaskTraceCarrierTest.php new file mode 100644 index 000000000..cecb34a6c --- /dev/null +++ b/tests/Unit/V2/TaskTraceCarrierTest.php @@ -0,0 +1,170 @@ +withPropagator(new MultiTextMapPropagator([ + TraceContextPropagator::getInstance(), BaggagePropagator::getInstance(), + ]))->activate(); + $parent = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => self::PARENT, + 'tracestate' => 'vendor=opaque', + ], context: Context::getCurrent()); + $scope = Baggage::getBuilder()->set('private', 'must-not-queue')->build()->storeInContext($parent)->activate(); + try { + $carrier = TaskTraceCarrier::capture('task-1'); + self::assertSame(self::envelope(), $carrier); + self::assertSame(self::envelope()['context'], TaskTraceCarrier::fromJob($carrier, 'task-1')); + self::assertLessThanOrEqual(4096, strlen(json_encode($carrier, JSON_THROW_ON_ERROR))); + self::assertSame('must-not-queue', Baggage::getCurrent()->getValue('private')); + } finally { + $scope->detach(); + $configuration->detach(); + } + self::assertSame($initial, Context::getCurrent()); + } + + public static function invalidCarriers(): iterable + { + yield 'missing' => [null, 'task-1']; + yield 'scalar' => ['invalid', 'task-1']; + yield 'empty' => [[], 'task-1']; + foreach ([ + 'schema' => 'other', + 'version' => '1', + 'task_id' => 'other-task', + 'context' => null, + ] as $key => $value) { + yield $key => [array_replace(self::envelope(), [ + $key => $value, + ]), 'task-1']; + } + yield 'extra field' => [self::envelope() + [ + 'extra' => 'private', + ], 'task-1']; + yield 'wrong task type' => [array_replace(self::envelope(), [ + 'task_id' => 1, + ]), 'task-1']; + yield 'invalid expected task' => [self::envelope(), '']; + yield 'oversized task' => [array_replace(self::envelope(), [ + 'task_id' => str_repeat('x', 4097), + ]), str_repeat('x', 4097)]; + foreach ([ + 'invalid parent' => [ + 'traceparent' => 'invalid', + ], + 'oversized parent' => [ + 'traceparent' => str_repeat('x', 257), + ], + 'oversized state' => [ + 'traceparent' => self::PARENT, + 'tracestate' => str_repeat('x', 513), + ], + 'baggage' => [ + 'traceparent' => self::PARENT, + 'baggage' => 'private=discard', + ], + ] as $name => $headers) { + $envelope = self::envelope(); + $envelope['context']['carrier'] = $headers; + yield $name => [$envelope, 'task-1']; + } + } + + #[DataProvider('invalidCarriers')] + public function testInvalidMetadataSuppliesNoContext(mixed $value, string $taskId): void + { + $diagnostic = 'stale'; + self::assertNull(TaskTraceCarrier::fromJob($value, $taskId, $diagnostic)); + self::assertSame($value === null ? null : 'discarded', $diagnostic); + } + + public function testMetadataInspectionDoesNotExecuteUserObjectSerialization(): void + { + $object = new class() implements JsonSerializable { + public int $calls = 0; + + public function jsonSerialize(): mixed + { + ++$this->calls; + + return 'must-not-execute'; + } + }; + self::assertNull(TaskTraceCarrier::fromJob($object, 'task-1')); + $envelope = self::envelope(); + $envelope['context']['carrier']['traceparent'] = $object; + self::assertNull(TaskTraceCarrier::fromJob($envelope, 'task-1')); + self::assertSame(0, $object->calls); + } + + public function testPublishedSerializedJobsLoadAndDefaultNullAddsNoWireBytes(): void + { + // Constructor-only captures from the released image; these are not hand-authored objects. + $fixture = json_decode( + file_get_contents(__DIR__ . '/../../Fixtures/V2/tracing-legacy-queue-jobs.json'), + true, + flags: JSON_THROW_ON_ERROR + ); + $allowed = [RunWorkflowTask::class, RunActivityTask::class, RunTimerTask::class]; + foreach ($fixture['jobs'] as $index => $original) { + $job = unserialize($original['serialized'], [ + 'allowed_classes' => $allowed, + ]); + self::assertInstanceOf($original['class'], $job); + self::assertSame('legacy-task-' . $index, $job->taskId); + self::assertTrue($job->afterCommit); + self::assertTrue(property_exists($job, 'dispatchObservabilityContext')); + self::assertNull($job->dispatchObservabilityContext); + self::assertSame($original['serialized'], serialize($job)); + self::assertSame($original['serialized'], serialize(new ($original['class'])($job->taskId))); + $job->dispatchObservabilityContext = 'malformed'; + $restored = unserialize(serialize($job), [ + 'allowed_classes' => $allowed, + ]); + self::assertSame($job->taskId, $restored->taskId); + self::assertTrue($restored->afterCommit); + self::assertSame('malformed', $restored->dispatchObservabilityContext); + self::assertNull(TaskTraceCarrier::fromJob($restored->dispatchObservabilityContext, $restored->taskId)); + } + } + + private static function envelope(): array + { + return [ + 'schema' => 'durable-workflow.queue-trace', + 'version' => 1, + 'task_id' => 'task-1', + 'context' => [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => [ + 'traceparent' => self::PARENT, + 'tracestate' => 'vendor=opaque', + ], + ], + ]; + } +} diff --git a/tests/Unit/V2/TaskTraceTimingsTest.php b/tests/Unit/V2/TaskTraceTimingsTest.php new file mode 100644 index 000000000..ae6af2513 --- /dev/null +++ b/tests/Unit/V2/TaskTraceTimingsTest.php @@ -0,0 +1,143 @@ + '2026-10-15 10:00:10.123450', + 'leased_at' => '2026-10-15 10:00:10.373451', + 'payload' => json_encode([ + 'retry_after_attempt_id' => 'attempt-1', + 'retry_after_attempt' => 1, + 'retry_backoff_seconds' => 7, + ], JSON_THROW_ON_ERROR), + ]); + $before = $task->getRawOriginal(); + self::assertSame([ + 'durable.task.queue_wait_seconds' => 0.250001, + 'durable.task.retry_backoff_seconds' => 7, + ], TaskTraceTimings::forTask($task)); + self::assertSame($before, $task->getRawOriginal()); + self::assertSame([], $task->getRelations()); + + $task->task_type = 'timer'; + $task->payload = [ + 'timer_id' => 'timer-1', + ]; + $timer = new WorkflowTimer(); + $timer->setRawAttributes([ + 'id' => 'timer-1', + 'workflow_run_id' => 'run-1', + 'delay_seconds' => 7, + 'fire_at' => '2026-10-15 10:00:09.373450', + ], true); + self::assertSame([ + 'durable.task.queue_wait_seconds' => 0.250001, + 'durable.timer.requested_wait_seconds' => 7, + 'durable.timer.lateness_seconds' => 1.000001, + ], TaskTraceTimings::forTask($task, $timer)); + $timer->workflow_run_id = 'foreign'; + self::assertSame([ + 'durable.task.queue_wait_seconds' => 0.250001, + ], TaskTraceTimings::forTask($task, $timer)); + } + + public static function invalidDates(): array + { + return [ + 'missing eligibility' => [null, '2026-10-15 10:00:10'], + 'missing claim' => ['2026-10-15 10:00:10', null], + 'invalid' => ['not-a-date', '2026-10-15 10:00:10'], + 'calendar normalization' => ['2026-02-30 10:00:10', '2026-03-03 10:00:10'], + 'time normalization' => ['2026-10-15 25:00:00', '2026-10-16 02:00:00'], + 'negative clock observation' => ['2026-10-15 10:00:11', '2026-10-15 10:00:10'], + ]; + } + + #[DataProvider('invalidDates')] + public function testMissingInvalidAndNegativeTimesProduceNoFabricatedQueueWait( + ?string $available, + ?string $leased + ): void { + self::assertSame([], TaskTraceTimings::forTask(self::task([ + 'available_at' => $available, + 'leased_at' => $leased, + ]))); + } + + public function testStoredSecondAndFractionalPrecisionAndZeroBackoffRemainValid(): void + { + foreach (['2026-10-15 10:00:10', '2026-10-15 10:00:10.1', '2026-10-15 10:00:10.12345'] as $time) { + $task = self::task([ + 'available_at' => $time, + 'leased_at' => $time, + ]); + self::assertSame([ + 'durable.task.queue_wait_seconds' => 0.0, + ], TaskTraceTimings::forTask($task)); + } + $task->payload = [ + 'retry_after_attempt_id' => 'attempt-1', + 'retry_after_attempt' => 1, + 'retry_backoff_seconds' => 0, + ]; + self::assertSame(0, TaskTraceTimings::forTask($task)['durable.task.retry_backoff_seconds']); + foreach ([-1, '7', false, null, 0.5] as $backoff) { + $task->payload = [ + 'retry_after_attempt_id' => 'attempt-1', + 'retry_after_attempt' => 1, + 'retry_backoff_seconds' => $backoff, + ]; + self::assertArrayNotHasKey('durable.task.retry_backoff_seconds', TaskTraceTimings::forTask($task)); + } + } + + public function testThrowingOptionalDateAndPayloadCastsCannotInterruptBusinessMetadata(): void + { + $task = new class() extends WorkflowTask { + public function getAttribute($key): mixed + { + if (in_array($key, ['available_at', 'leased_at', 'payload'], true)) { + throw new RuntimeException('private-optional-cast'); + } + + return parent::getAttribute($key); + } + }; + $task->setRawAttributes([ + 'id' => 'task-1', + 'task_type' => 'activity', + 'workflow_run_id' => 'run-1', + ], true); + $before = $task->getRawOriginal(); + self::assertSame([], TaskTraceTimings::forTask($task)); + self::assertSame($before, $task->getRawOriginal()); + } + + private static function task(array $attributes): WorkflowTask + { + $task = new WorkflowTask(); + $task->setRawAttributes(array_replace([ + 'id' => 'task-1', + 'workflow_run_id' => 'run-1', + 'task_type' => 'activity', + 'available_at' => null, + 'leased_at' => null, + 'payload' => '{}', + ], $attributes), true); + + return $task; + } +} diff --git a/tests/Unit/V2/TimingExecutionTracingTest.php b/tests/Unit/V2/TimingExecutionTracingTest.php new file mode 100644 index 000000000..6591c69ff --- /dev/null +++ b/tests/Unit/V2/TimingExecutionTracingTest.php @@ -0,0 +1,177 @@ + [true, true], + 'disabled' => [false, false], + 'legacy admission' => [false, true], + ]; + } + + #[DataProvider('modes')] + public function testActualTimerAndRetryWorkersSeparateStoredWaitingFromProcessing( + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-timings-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $admissionEnabled, $workerEnabled): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_TIMINGS' => 'true', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + $workers = [$worker('work')]; + self::assertSame('waiting', $workers[0]['status']); + $scheduled = $workers[0]['timings']['timer']; + sleep(2); + $workers[] = $worker('work'); + $workers[] = $worker('work'); + $workers[] = $worker('work'); + self::assertSame('failed', $workers[3]['timings']['attempts'][0]['status']); + $retryPolicy = $workers[3]['timings']['policy']; + sleep(2); + $workers[] = $worker('work'); + $workers[] = $worker('work'); + $finished = $worker('inspect'); + self::assertSame( + 'completed', + $finished['status'], + json_encode($finished['timings']['history_types'], JSON_THROW_ON_ERROR) + ); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertSame(1, $finished['timings']['completion_count']); + self::assertSame($retryPolicy, $finished['timings']['policy']); + self::assertSame([1], $retryPolicy['backoff_seconds']); + self::assertSame(2, $retryPolicy['max_attempts']); + self::assertSame(['failed', 'completed'], array_column($finished['timings']['attempts'], 'status')); + self::assertSame([1, 2], array_column($finished['timings']['attempts'], 'attempt_number')); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $finished[$field]); + } + foreach (['id', 'fire_at', 'delay_seconds', 'created_at'] as $field) { + self::assertSame($scheduled[$field], $finished['timings']['timer'][$field]); + } + self::assertSame('fired', $finished['timings']['timer']['status']); + foreach ($workers as $prefix) { + self::assertSame($prefix['timings']['history_hashes'], array_slice( + $finished['timings']['history_hashes'], + 0, + count($prefix['timings']['history_hashes']) + )); + } + $tasks = array_column($finished['timings']['tasks'], null, 'id'); + self::assertCount(6, $tasks); + foreach ($tasks as $task) { + self::assertSame('completed', $task['status']); + self::assertSame($task['payload']['retry_after_attempt'] ?? 0, $task['attempt_count'] - 1); + } + $spans = array_merge(...array_column($workers, 'execution_spans')); + self::assertCount($workerEnabled ? 6 : 0, $spans); + foreach ($spans as $span) { + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + $task = $tasks[$span['fields']['task_id']]; + $seconds = self::seconds($task['available_at'], $task['leased_at']); + self::assertEqualsWithDelta($seconds, $span['fields']['durable.task.queue_wait_seconds'], 0.000001); + if (isset($task['payload']['retry_backoff_seconds'])) { + self::assertSame(1, $span['fields']['durable.task.retry_backoff_seconds']); + self::assertSame(2, $span['fields']['activity_attempt_number']); + } else { + self::assertArrayNotHasKey('durable.task.retry_backoff_seconds', $span['fields']); + } + if ($task['type'] === 'timer') { + self::assertSame('durable.timer.task', $span['name']); + self::assertSame($scheduled['id'], $span['fields']['timer_id']); + self::assertSame(1, $span['fields']['durable.timer.requested_wait_seconds']); + self::assertEqualsWithDelta( + self::seconds($scheduled['fire_at'], $task['leased_at']), + $span['fields']['durable.timer.lateness_seconds'], + 0.000001 + ); + } else { + self::assertArrayNotHasKey('durable.timer.requested_wait_seconds', $span['fields']); + } + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + self::assertCount( + 2, + array_filter($records, static fn (array $record): bool => $record['message'] === 'callback') + ); + foreach ($records as $record) { + foreach (array_keys($record['context']) as $key) { + self::assertStringNotContainsString('queue_wait_seconds', $key); + self::assertStringNotContainsString('retry_backoff_seconds', $key); + } + if ($record['message'] === 'worker outside' || ! $workerEnabled) { + self::assertArrayNotHasKey('trace_id', $record['context']); + } + } + self::assertStringNotContainsString( + 'durable.task.queue_wait_seconds', + json_encode($finished['timings']['history_payloads'], JSON_THROW_ON_ERROR) + ); + self::assertCount(6, array_unique(array_column($workers, 'pid'))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function seconds(string $start, string $end): float + { + $from = new DateTimeImmutable($start, new DateTimeZone('UTC')); + $to = new DateTimeImmutable($end, new DateTimeZone('UTC')); + + return ($to->getTimestamp() - $from->getTimestamp()) + + ((int) $to->format('u') - (int) $from->format('u')) / 1_000_000; + } +} diff --git a/tests/Unit/V2/TraceCarrierTest.php b/tests/Unit/V2/TraceCarrierTest.php new file mode 100644 index 000000000..853cec0c9 --- /dev/null +++ b/tests/Unit/V2/TraceCarrierTest.php @@ -0,0 +1,294 @@ + TraceCarrier::hasPreparedAdmission()); + $fiber->start(); + self::assertFalse($fiber->getReturn()); + self::assertTrue(TraceCarrier::hasPreparedAdmission()); + }); + self::assertFalse(TraceCarrier::hasPreparedAdmission()); + self::assertSame($initial, Context::getCurrent()); + } + + public function testPreparedAdmissionCanNestWithinAnExplicitlyBoundFiber(): void + { + $initial = Context::getCurrent(); + $fiber = new Fiber(static function () use ($initial): void { + $bound = $initial->activate(); + try { + TraceCarrier::withPreparedAdmission(static function (): void { + $outer = Context::getCurrent(); + self::assertTrue(TraceCarrier::hasPreparedAdmission()); + TraceCarrier::withPreparedAdmission(static function (): void { + self::assertTrue(TraceCarrier::hasPreparedAdmission()); + }); + self::assertTrue(TraceCarrier::hasPreparedAdmission()); + self::assertSame($outer, Context::getCurrent()); + }); + self::assertFalse(TraceCarrier::hasPreparedAdmission()); + } finally { + $bound->detach(); + } + }); + $fiber->start(); + self::assertTrue($fiber->isTerminated()); + self::assertSame($initial, Context::getCurrent()); + } + + public function testPreparedAdmissionRestoresItsScopeAfterTheOriginalFailure(): void + { + $initial = Context::getCurrent(); + $failure = new RuntimeException('original admission failure'); + $calls = 0; + try { + TraceCarrier::withPreparedAdmission(static function () use ($failure, &$calls): never { + ++$calls; + throw $failure; + }); + self::fail('The original business failure must be preserved.'); + } catch (RuntimeException $caught) { + self::assertSame($failure, $caught); + } + self::assertSame(1, $calls); + self::assertFalse(TraceCarrier::hasPreparedAdmission()); + self::assertSame($initial, Context::getCurrent()); + } + + public function testValidCarrierPreservesTraceIdentityAndState(): void + { + $input = self::envelope([ + 'traceparent' => self::PARENT, + 'tracestate' => 'vendor=opaque', + ]); + $this->assertSame($input, TraceCarrier::normalize($input)); + } + + public function testAbsentStoredMetadataDoesNotRequireApplicationInstrumentation(): void + { + $run = new WorkflowRun(); + $this->assertNull(TraceCarrier::fromRun($run)); + $run->setRawAttributes([ + 'observability_context' => null, + ]); + $this->assertNull(TraceCarrier::fromRun($run)); + } + + public function testMetadataReadFailureIsDiscardedWithoutInterruptingExecution(): void + { + $run = new class() extends WorkflowRun { + public function getAttributes(): array + { + return [ + 'observability_context' => 'unreadable', + ]; + } + + public function getAttribute($key): mixed + { + throw new RuntimeException('optional telemetry cast failed'); + } + }; + $this->assertNull(TraceCarrier::fromRun($run)); + } + + public function testBaggageIsAbsentUnlessExplicitlyAllowlisted(): void + { + $input = self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => 'approved=hello%20world;property=yes,private=secret', + ]); + $this->assertSame(self::envelope([ + 'traceparent' => self::PARENT, + ]), TraceCarrier::normalize($input)); + $result = TraceCarrier::normalize($input, ['approved']); + $this->assertNotNull($result); + $context = BaggagePropagator::getInstance()->extract($result['carrier'], context: Context::getRoot()); + $baggage = Baggage::fromContext($context); + $this->assertSame('hello world', $baggage->getValue('approved')); + $this->assertSame('property=yes', $baggage->getEntry('approved')?->getMetadata()->getValue()); + $this->assertNull($baggage->getEntry('private')); + $this->assertSame($result, TraceCarrier::normalize($result, ['approved'])); + } + + public function testExactlyThirtyTwoBaggageEntriesAreAllowed(): void + { + $keys = array_map(static fn (int $i): string => 'key' . $i, range(1, 33)); + $header = implode(',', array_map(static fn (string $key): string => $key . '=value', $keys)); + $input = self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => $header, + ]); + $this->assertNotNull(TraceCarrier::normalize($input, array_slice($keys, 0, 32))); + $this->assertNull(TraceCarrier::normalize($input, $keys)); + } + + public function testCaptureUsesTheApplicationPropagatorAndRestoresItsScope(): void + { + $initial = Context::getCurrent(); + $configured = Configurator::create()->withPropagator(new MultiTextMapPropagator([ + TraceContextPropagator::getInstance(), + BaggagePropagator::getInstance(), + ]))->activate(); + $parent = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => self::PARENT, + ]); + $parent = Baggage::getBuilder()->set('approved', 'value')->set('private', 'secret')->build()->storeInContext( + $parent + ); + $scope = $parent->activate(); + try { + $result = TraceCarrier::capture(['approved']); + $this->assertNotNull($result); + $this->assertSame(self::PARENT, $result['carrier']['traceparent']); + $this->assertSame('approved=value', $result['carrier']['baggage']); + $this->assertSame($parent, Context::getCurrent()); + $this->assertNull(TraceCarrier::normalize(self::envelope([ + 'traceparent' => 'invalid', + ]))); + $this->assertSame($parent, Context::getCurrent()); + $this->assertTrue(Span::getCurrent()->getContext()->isValid()); + } finally { + $scope->detach(); + $configured->detach(); + } + $this->assertSame($initial, Context::getCurrent()); + } + + #[DataProvider('invalidCarriers')] + public function testInvalidTelemetryIsDiscarded(mixed $input): void + { + $diagnostic = 'stale'; + $this->assertNull(TraceCarrier::normalize($input, ['approved'], $diagnostic)); + $this->assertSame($input === null ? null : 'discarded', $diagnostic); + } + + public function testFilteringReportsOnlyAFixedDiagnosticAndKeepsTheCarrierSchemaUnchanged(): void + { + $input = self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => 'approved=value,private=secret', + ]); + $normalized = TraceCarrier::normalize($input, ['approved'], $diagnostic); + self::assertSame('filtered', $diagnostic); + self::assertSame(self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => 'approved=value', + ]), $normalized); + self::assertSame($normalized, TraceCarrier::normalize($normalized, ['approved'], $diagnostic)); + self::assertNull($diagnostic); + } + + public static function invalidCarriers(): array + { + return [ + 'absent' => [null], + 'string' => ['invalid'], + 'unknown schema' => [[ + 'schema' => 'other', + 'version' => 1, + 'carrier' => [ + 'traceparent' => self::PARENT, + ], + ]], + 'future version' => [[ + 'schema' => 'durable-workflow.trace-context', + 'version' => 2, + 'carrier' => [ + 'traceparent' => self::PARENT, + ], + ]], + 'string version' => [[ + 'schema' => 'durable-workflow.trace-context', + 'version' => '1', + 'carrier' => [ + 'traceparent' => self::PARENT, + ], + ]], + 'unknown metadata' => [ + self::envelope([ + 'traceparent' => self::PARENT, + ]) + [ + 'input' => 'private', + ]], + 'missing traceparent' => [ + self::envelope([ + 'tracestate' => 'vendor=value', + ])], + 'malformed traceparent' => [ + self::envelope([ + 'traceparent' => 'invalid', + ])], + 'zero trace identity' => [ + self::envelope([ + 'traceparent' => '00-00000000000000000000000000000000-00f067aa0ba902b7-01', + ])], + 'zero span identity' => [ + self::envelope([ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01', + ])], + 'oversized traceparent' => [ + self::envelope([ + 'traceparent' => str_repeat('a', 257), + ])], + 'oversized tracestate' => [ + self::envelope([ + 'traceparent' => self::PARENT, + 'tracestate' => str_repeat('a', 513), + ])], + 'oversized baggage' => [ + self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => str_repeat('a', 2049), + ])], + 'array header' => [ + self::envelope([ + 'traceparent' => [self::PARENT], + ])], + 'unknown header' => [ + self::envelope([ + 'traceparent' => self::PARENT, + 'authorization' => 'private', + ])], + 'invalid utf8' => [ + self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => "approved=\xff", + ])], + ]; + } + + private static function envelope(array $carrier): array + { + return [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => $carrier, + ]; + } +} diff --git a/tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php b/tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php new file mode 100644 index 000000000..9d1b829cd --- /dev/null +++ b/tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php @@ -0,0 +1,145 @@ +getTrace()[0]); + $result = TracingQueueErrorDiagnostics::context($error); + self::assertCount(8, $result['stage_frames']); + foreach ($result['stage_frames'] as $frame) { + self::assertSame(['file', 'line', 'function'], array_keys($frame)); + self::assertSame('TracingQueueErrorDiagnosticsTest.php', $frame['file']); + } + self::assertStringNotContainsString('private-argument-sentinel', json_encode($result, JSON_THROW_ON_ERROR)); + self::assertNull($result['statement']); + self::assertFalse($result['statement_truncated']); + } + + public function testOnlyKnownFailedJobLoggerContentionIsClassified(): void + { + $error = self::failedJobLogRefusal(); + self::assertTrue(TracingQueueErrorDiagnostics::isFailedJobLogContention($error)); + $error['native_code'] = 6; + self::assertTrue(TracingQueueErrorDiagnostics::isFailedJobLogContention($error)); + } + + #[DataProvider('unsafeRefusals')] + public function testOtherInsertErrorsRemainRefusals(array $changes): void + { + self::assertFalse(TracingQueueErrorDiagnostics::isFailedJobLogContention( + array_replace(self::failedJobLogRefusal(), $changes), + )); + } + + public static function unsafeRefusals(): array + { + return [ + 'workflow write' => [[ + 'statement' => 'insert into "workflow_history_events" ("payload") values (?)', + ]], + 'uncertain statement' => [[ + 'statement_truncated' => true, + ]], + 'other database failure' => [[ + 'native_code' => 19, + ]], + 'active root' => [[ + 'transaction_level' => 1, + ]], + 'pdo still active' => [[ + 'pdo_in_transaction' => true, + ]], + 'unknown pdo' => [[ + 'pdo_in_transaction' => null, + ]], + 'unknown caller' => [[ + 'stage_frames' => [], + ]], + 'application insert' => [[ + 'stage_frames' => [[ + 'file' => 'Application.php', + 'function' => 'insert', + ]], + ]], + 'malformed frames' => [[ + 'stage_frames' => 'not-frames', + ]], + ]; + } + + private static function failedJobLogRefusal(): array + { + return [ + 'error_class' => QueryException::class, + 'native_code' => 5, + 'operation' => 'insert', + 'transaction_level' => 0, + 'pdo_in_transaction' => false, + 'statement_truncated' => false, + 'statement' => 'insert into "failed_jobs" ' + . '("uuid", "connection", "queue", "payload", "exception", "failed_at") values (?, ?, ?, ?, ?, ?)', + 'stage_frames' => [[ + 'file' => 'DatabaseUuidFailedJobProvider.php', + 'function' => 'insert', + ], [ + 'file' => 'WorkCommand.php', + 'function' => 'logFailedJob', + ]], + ]; + } + + private static function nestedError(int $depth, string $argument): Throwable + { + return $depth === 0 ? new RuntimeException('private-message-sentinel') : self::nestedError( + $depth - 1, + $argument + ); + } +} diff --git a/tests/Unit/V2/TransitionExecutionTracingTest.php b/tests/Unit/V2/TransitionExecutionTracingTest.php new file mode 100644 index 000000000..1c03398a8 --- /dev/null +++ b/tests/Unit/V2/TransitionExecutionTracingTest.php @@ -0,0 +1,209 @@ + ['child', true, true], + 'child, tracing disabled' => ['child', false, false], + 'continuation, tracing enabled' => ['continue', true, true], + 'continuation, tracing disabled' => ['continue', false, false], + 'legacy child, enabled later' => ['child', true, false], + 'legacy continuation, enabled later' => ['continue', true, false], + 'child, disabled workers' => ['child', false, true], + 'continuation, disabled workers' => ['continue', false, true], + 'child, wrong task carrier' => ['child', true, true, 'wrong task'], + 'child, wrong namespace carrier' => ['child', true, true, 'wrong namespace'], + 'child failure, tracing enabled' => ['child-failure', true, true], + 'child failure, tracing disabled' => ['child-failure', false, false], + 'legacy child failure, enabled later' => ['child-failure', true, false], + 'child failure, disabled workers' => ['child-failure', false, true], + ]; + } + + #[DataProvider('transitions')] + public function testColdSuccessorRetainsTheAdmissionCarrierWithoutChangingDurableExecution( + string $transition, + bool $enabled, + bool $admissionEnabled, + string $control = 'valid', + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-transition-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ( + $directory, + $transition, + $enabled, + $admissionEnabled, + $control + ): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_TRANSITION' => $transition, + 'DW_TEST_TRACING_CHILD_OUTCOME_CONTROL' => $control, + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $enabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame($admissionEnabled, $admitted['carrier'] !== null); + $scheduled = $worker('work'); + self::assertCount(2, $scheduled['runs']); + foreach ($scheduled['runs'] as $run) { + self::assertSame($admitted['carrier'], $run['carrier'], json_encode($run, JSON_THROW_ON_ERROR)); + } + $workers = [$scheduled]; + $altered = false; + for ($remaining = 8; $remaining > 0; --$remaining) { + $state = $worker('inspect'); + if ($state['jobs'] === 0) { + break; + } + $childClosed = array_filter($state['transition_events'], static fn (array $event): bool => + $event['type'] === 'WorkflowCompleted' && $event['run_id'] !== $admitted['run_id']); + if ($control !== 'valid' && ! $altered && $childClosed !== []) { + $changed = $worker('invalidate-child-outcome'); + self::assertSame($state['transition_events'], $changed['transition_events']); + self::assertSame($state['runs'], $changed['runs']); + self::assertNotNull($changed['child_outcome_control']); + $altered = true; + } + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame($control !== 'valid', $altered); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertSame($transition === 'child-failure' ? 0 : 1, $finished['activity_count']); + self::assertSame($transition === 'child-failure' ? 0 : 1, $finished['completion_count']); + self::assertCount(2, $finished['runs']); + foreach ($scheduled['runs'] as $index => $run) { + foreach (['id', 'instance_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($run[$key], $finished['runs'][$index][$key]); + } + self::assertSame($run['history_hashes'], array_slice( + $finished['runs'][$index]['history_hashes'], + 0, + count($run['history_hashes']), + )); + } + if ($transition === 'continue') { + self::assertNotSame($admitted['run_id'], $finished['run_id']); + self::assertSame($admitted['execution_deadline'], $finished['execution_deadline']); + self::assertSame('completed', $finished['runs'][0]['status']); + self::assertSame('continued', $finished['runs'][0]['closed_reason']); + self::assertSame($finished['runs'][0]['instance_id'], $finished['runs'][1]['instance_id']); + } else { + self::assertSame($admitted['run_id'], $finished['run_id']); + self::assertSame('completed', $finished['runs'][0]['status']); + self::assertNotSame($finished['runs'][0]['instance_id'], $finished['runs'][1]['instance_id']); + } + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + $spans = array_merge(...array_column($workers, 'execution_spans')); + if ($enabled) { + $observedRuns = []; + foreach ($spans as $span) { + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + $observedRuns[] = $span['fields']['workflow_run_id']; + } + foreach ($finished['runs'] as $run) { + self::assertContains($run['id'], $observedRuns); + } + } else { + self::assertSame([], $spans); + } + self::assertStringNotContainsString( + 'observability_context', + json_encode($finished['history_payloads'], JSON_THROW_ON_ERROR) + ); + if ($transition !== 'continue') { + self::assertChildOutcomeLinks( + $workers, + $finished, + $enabled, + $control === 'valid', + $transition === 'child-failure' + ); + } + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function assertChildOutcomeLinks( + array $workers, + array $finished, + bool $enabled, + bool $expected, + bool $failed, + ): void { + $spans = array_merge(...array_column($workers, 'spans')); + if (! $enabled) { + self::assertSame([], $spans); + return; + } + $events = $finished['transition_events']; + $childType = $failed ? 'WorkflowFailed' : 'WorkflowCompleted'; + $child = array_values(array_filter($events, static fn (array $event): bool => + $event['type'] === $childType && $event['run_id'] !== $finished['run_id']))[0]; + $parent = array_values(array_filter($events, static fn (array $event): bool => + $event['type'] === 'WorkflowCompleted' && $event['run_id'] === $finished['run_id']))[0]; + $resolved = array_values(array_filter($events, static fn (array $event): bool => + $event['type'] === ($failed ? 'ChildRunFailed' : 'ChildRunCompleted')))[0]; + self::assertSame($child['run_id'], $resolved['payload']['child_workflow_run_id']); + $childSpan = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === 'durable.workflow.task' && $span['fields']['task_id'] === $child['task_id']))[0]; + $parentSpan = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === 'durable.workflow.task' && $span['fields']['task_id'] === $parent['task_id']))[0]; + $links = array_values(array_filter($parentSpan['links'], static fn (array $link): bool => + ($link['fields']['durable.link.type'] ?? null) === 'child_outcome')); + self::assertCount($expected ? 1 : 0, $links); + if (! $expected) { + return; + } + self::assertSame($childSpan['trace_id'], $links[0]['trace_id']); + self::assertSame($childSpan['span_id'], $links[0]['span_id']); + self::assertSame($child['id'], $links[0]['fields']['history_event_id']); + self::assertSame($child['run_id'], $links[0]['fields']['child_workflow_run_id']); + self::assertSame($child['task_id'], $links[0]['fields']['task_id']); + self::assertSame($resolved['id'], $links[0]['fields']['parent_resolution_event_id']); + } +} diff --git a/tests/Unit/V2/UpdateLinkExecutionTracingTest.php b/tests/Unit/V2/UpdateLinkExecutionTracingTest.php new file mode 100644 index 000000000..17925c1d1 --- /dev/null +++ b/tests/Unit/V2/UpdateLinkExecutionTracingTest.php @@ -0,0 +1,215 @@ + [true, true], + 'disabled' => [false, false], + 'legacy admission' => [false, true], + ]; + } + + #[DataProvider('modes')] + public function testUpdateCallersLinkOnlyAcceptedProcessingAndKeepOriginalDecisions( + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-update-links-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode, string $step = 'first', bool $duplicate = false) use ( + $directory, + $admissionEnabled, + $workerEnabled, + ): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_UPDATE_LINKS' => 'true', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + 'DW_TEST_TRACING_UPDATE_STEP' => $step, + 'DW_TEST_TRACING_UPDATE_CALLER' => $duplicate ? self::DUPLICATE_CALLER + : ($step === 'first' ? self::FIRST_CALLER : self::FAILED_CALLER), + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + $workers = [$worker('work')]; + self::assertSame('waiting', $workers[0]['status']); + self::assertSame(0, $workers[0]['jobs']); + $accepted = $worker('update'); + self::assertTrue($accepted['updates']['submitted']['accepted']); + self::assertSame( + $workerEnabled ? array_fill( + 0, + 1 + count($accepted['spans']) - count($accepted['execution_spans']), + 0 + ) : [], + $accepted['updates']['capture_transaction_levels'] + ); + $duplicate = $worker('update', duplicate: true); + self::assertSame($accepted['updates']['submitted'], $duplicate['updates']['submitted']); + self::assertSame($accepted['updates']['commands'], $duplicate['updates']['commands']); + self::assertSame($accepted['updates']['history_hashes'], $duplicate['updates']['history_hashes']); + $workers[] = $worker('work'); + self::assertSame('completed', $workers[1]['updates']['rows'][0]['status']); + self::assertSame([9007199254740993, null, '雪'], $workers[1]['updates']['rows'][0]['result']); + $failed = $worker('update', 'failure'); + self::assertTrue($failed['updates']['submitted']['accepted']); + $workers[] = $worker('work'); + self::assertSame('failed', $workers[2]['updates']['rows'][1]['status']); + self::assertSame('deliberate update failure', $workers[2]['updates']['rows'][1]['failure_message']); + $workers[] = $worker('work'); + $refused = $worker('update', 'invalid'); + self::assertFalse($refused['updates']['submitted']['accepted']); + self::assertNull($refused['updates']['commands'][2]['carrier']); + $signalled = $worker('signal'); + for ($remaining = 6; $remaining > 0 && $worker('inspect')['jobs'] > 0; --$remaining) { + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status'], json_encode([ + 'history' => $finished['updates']['history_types'], + 'tasks' => $finished['updates']['tasks'], + 'failed_jobs' => $finished['failed_jobs'], + ], JSON_THROW_ON_ERROR)); + self::assertSame([ + 'updates' => [[9007199254740993, null, '雪']], + 'activity' => [9007199254740993, null, '雪'], + ], $finished['output']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $finished[$field]); + } + foreach ([$admitted, $accepted, $failed, $refused, $signalled] as $prefix) { + self::assertSame($prefix['updates']['history_hashes'], array_slice( + $finished['updates']['history_hashes'], + 0, + count($prefix['updates']['history_hashes']) + )); + } + self::assertSame( + $accepted['updates']['commands'][0]['immutable'], + $finished['updates']['commands'][0]['immutable'] + ); + self::assertSame( + $failed['updates']['commands'][1]['immutable'], + $finished['updates']['commands'][1]['immutable'] + ); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertCount(7, $finished['updates']['tasks']); + foreach ($finished['updates']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + } + $spans = array_merge(...array_column($workers, 'execution_spans')); + self::assertCount($workerEnabled ? 7 : 0, $spans); + foreach ($spans as $index => $span) { + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + if ($index === 1 || $index === 2) { + self::assertCount(1, $span['links']); + $caller = $index === 1 ? self::FIRST_CALLER : self::FAILED_CALLER; + self::assertSame(explode('-', $caller)[1], $span['links'][0]['trace_id']); + self::assertSame(explode('-', $caller)[2], $span['links'][0]['span_id']); + self::assertSame('update', $span['links'][0]['fields']['command_type']); + self::assertSame( + $finished['updates']['commands'][$index - 1]['id'], + $span['links'][0]['fields']['workflow_command_id'] + ); + } elseif ($index === 4) { + self::assertCount(1, $span['links']); + self::assertSame('signal', $span['links'][0]['fields']['command_type']); + self::assertSame('77777777777777777777777777777777', $span['links'][0]['trace_id']); + self::assertSame('8888888888888888', $span['links'][0]['span_id']); + self::assertSame( + $finished['updates']['signal_command_id'], + $span['links'][0]['fields']['workflow_command_id'] + ); + } else { + self::assertSame([], $span['links']); + } + } + foreach (array_slice($finished['updates']['commands'], 0, 2) as $index => $command) { + if ($workerEnabled) { + self::assertSame($index === 0 ? self::FIRST_CALLER : self::FAILED_CALLER, json_decode( + $command['carrier'], + true, + flags: JSON_THROW_ON_ERROR + )['carrier']['traceparent']); + } else { + self::assertNull($command['carrier']); + } + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + self::assertCount( + 1, + array_filter($records, static fn (array $record): bool => $record['message'] === 'callback') + ); + foreach ($records as $record) { + if ($record['message'] === 'update handler') { + self::assertSame( + $admissionEnabled && $workerEnabled ? 'origin' : null, + $record['context']['approved_baggage'] + ); + } + if ($record['message'] === 'worker outside' || ! $workerEnabled) { + self::assertArrayNotHasKey('trace_id', $record['context']); + self::assertArrayNotHasKey('workflow_run_id', $record['context']); + } + } + self::assertStringNotContainsString( + 'observability_context', + json_encode($finished['updates']['history_payloads'], JSON_THROW_ON_ERROR) + ); + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +}