diff --git a/app/Support/RuntimeLocalExternalPayloadStorage.php b/app/Support/RuntimeLocalExternalPayloadStorage.php index 8d8cfa1b..fd013c21 100644 --- a/app/Support/RuntimeLocalExternalPayloadStorage.php +++ b/app/Support/RuntimeLocalExternalPayloadStorage.php @@ -2,6 +2,7 @@ namespace App\Support; +use Closure; use InvalidArgumentException; use RuntimeException; @@ -33,19 +34,12 @@ public function __construct(string $root) public function put(string $data, string $sha256, string $codec): string { - $uri = $this->uriFor($sha256, $codec); - $path = rawurldecode((string) parse_url($uri, PHP_URL_PATH)); - $directory = dirname($path); - - if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) { - throw new RuntimeException(sprintf('Unable to create external payload directory [%s].', $directory)); - } - - if (! is_file($path) && file_put_contents($path, $data, LOCK_EX) === false) { - throw new RuntimeException(sprintf('Unable to write external payload [%s].', $path)); - } - - return $uri; + return $this->commit( + $this->uriFor($sha256, $codec), + $sha256, + strlen($data), + static fn ($output) => fwrite($output, $data), + ); } public function uriFor(string $sha256, string $codec): string @@ -74,7 +68,16 @@ public function get(string $uri): string public function putStream($stream, string $sha256, string $codec): string { - $uri = $this->uriFor($sha256, $codec); + return $this->commit( + $this->uriFor($sha256, $codec), + $sha256, + fstat($stream)['size'] ?? null, + static fn ($output) => stream_copy_to_stream($stream, $output), + ); + } + + private function commit(string $uri, string $sha256, ?int $expectedSize, Closure $write): string + { $path = rawurldecode((string) parse_url($uri, PHP_URL_PATH)); $directory = dirname($path); if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) { @@ -89,26 +92,24 @@ public function putStream($stream, string $sha256, string $codec): string } try { - $expectedSize = fstat($stream)['size'] ?? null; if (! flock($output, LOCK_EX)) { throw new RuntimeException('Unable to lock external payload bytes.'); } // Never truncate an already accepted object on an idempotent retry. $existingHash = hash_init('sha256'); - if (fstat($output)['size'] === $expectedSize + $matches = fstat($output)['size'] === $expectedSize && hash_update_stream($existingHash, $output) === $expectedSize - && hash_equals($sha256, hash_final($existingHash)) - ) { - return $uri; + && hash_equals($sha256, hash_final($existingHash)); + + if (! $matches && (! rewind($output) || ! ftruncate($output, 0) + || ($written = $write($output)) === false + || ($expectedSize !== null && $written !== $expectedSize))) { + throw new RuntimeException('Unable to commit external payload bytes.'); } - if (! rewind($output) || ! ftruncate($output, 0) - || ($written = stream_copy_to_stream($stream, $output)) === false - || ($expectedSize !== null && $written !== $expectedSize) - || ! fflush($output) - || ! fsync($output) - ) { + // A retry may follow a failed sync even when the bytes already match. + if (! fflush($output) || ! fsync($output)) { throw new RuntimeException('Unable to commit external payload bytes.'); } } finally { diff --git a/composer.json b/composer.json index 425efc17..fa0223f2 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ }, "extra": { "durable-workflow": { - "product-train": "2.3.9" + "product-train": "2.3.10" }, "laravel": { "dont-discover": [] diff --git a/composer.lock b/composer.lock index f5279482..cb32fc83 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "825c44feb5e9791da2469d1db41dd6af", + "content-hash": "c24556dcc255cd60f7b718a61874338c", "packages": [ { "name": "apache/avro", diff --git a/docker-compose.dedicated-matching.yml b/docker-compose.dedicated-matching.yml index 513274a1..6d536830 100644 --- a/docker-compose.dedicated-matching.yml +++ b/docker-compose.dedicated-matching.yml @@ -32,13 +32,13 @@ name: durable-workflow-server # daemon reports `shape: dedicated`. # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.3.9}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.3.10}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.3.9}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.3.10}} APP_DEBUG: ${APP_DEBUG:-false} DB_CONNECTION: mysql DB_HOST: mysql diff --git a/docker-compose.memo-rolling.yml b/docker-compose.memo-rolling.yml index 61878bfb..b7b04ddf 100644 --- a/docker-compose.memo-rolling.yml +++ b/docker-compose.memo-rolling.yml @@ -49,14 +49,14 @@ services: command: ["server-bootstrap"] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.3.9} + APP_VERSION: ${APP_VERSION:-2.3.10} successor: image: ${DW_MEMO_SUCCESSOR_IMAGE:-durable-workflow/server-memo-rolling:local} ports: !override [] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.3.9} + APP_VERSION: ${APP_VERSION:-2.3.10} DW_SERVER_ID: memo-successor DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node diff --git a/docker-compose.published.yml b/docker-compose.published.yml index 2085b97f..3bcf5d49 100644 --- a/docker-compose.published.yml +++ b/docker-compose.published.yml @@ -1,13 +1,13 @@ name: durable-workflow-server # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.3.9}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.3.10}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.3.9}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.3.10}} APP_DEBUG: ${APP_DEBUG:-false} LOG_CHANNEL: ${LOG_CHANNEL:-stderr} LOG_LEVEL: ${LOG_LEVEL:-info} diff --git a/docker-compose.small-cluster.yml b/docker-compose.small-cluster.yml index 46604bfa..47114323 100644 --- a/docker-compose.small-cluster.yml +++ b/docker-compose.small-cluster.yml @@ -12,7 +12,7 @@ x-server-build: &server-build x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: testing - APP_VERSION: ${APP_VERSION:-2.3.9} + APP_VERSION: ${APP_VERSION:-2.3.10} APP_DEBUG: "false" DW_SERVER_KEY: ${DW_SERVER_KEY:-base64:5Zt4nUhlCm3DD0nLXZJQdHiwPfb56yGo9gNV/g3jYbY=} DB_CONNECTION: ${DW_SMALL_CLUSTER_DB:-mysql} diff --git a/docker-compose.yml b/docker-compose.yml index d9630e70..dfa5a75b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node - APP_VERSION: "${APP_VERSION:-2.3.9}" + APP_VERSION: "${APP_VERSION:-2.3.10}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -62,7 +62,7 @@ services: APP_NAME: "Durable Workflow Server" APP_ENV: local DW_SERVER_KEY: "${DW_SERVER_KEY:-}" - APP_VERSION: "${APP_VERSION:-2.3.9}" + APP_VERSION: "${APP_VERSION:-2.3.10}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -124,7 +124,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: worker_node - APP_VERSION: "${APP_VERSION:-2.3.9}" + APP_VERSION: "${APP_VERSION:-2.3.10}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 @@ -177,7 +177,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: scheduler_node - APP_VERSION: "${APP_VERSION:-2.3.9}" + APP_VERSION: "${APP_VERSION:-2.3.10}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 diff --git a/k8s/README.md b/k8s/README.md index 78bfe54f..8f4be999 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -13,7 +13,7 @@ The checked-in manifests are synchronized with the repository's stable source release and pin its Docker Hub tag: ```text -durableworkflow/server:2.3.9 +durableworkflow/server:2.3.10 ``` Before production use, patch every workload image to the exact published tag or @@ -21,15 +21,15 @@ digest you intend to run: ```bash kubectl set image -n durable-workflow deploy/durable-workflow-server \ - server=durableworkflow/server:2.3.9 + server=durableworkflow/server:2.3.10 kubectl set image -n durable-workflow deploy/durable-workflow-worker \ - worker=durableworkflow/server:2.3.9 + worker=durableworkflow/server:2.3.10 kubectl set image -n durable-workflow cronjob/durable-workflow-scheduler \ - scheduler=durableworkflow/server:2.3.9 + scheduler=durableworkflow/server:2.3.10 ``` GitHub Container Registry publishes the same release line at -`ghcr.io/durable-workflow/server:2.3.9`. Digest pinning is preferred for strict +`ghcr.io/durable-workflow/server:2.3.10`. Digest pinning is preferred for strict change control. The manifests expect you to provide: diff --git a/k8s/helm/durable-workflow/Chart.yaml b/k8s/helm/durable-workflow/Chart.yaml index 528c921f..8f1da225 100644 --- a/k8s/helm/durable-workflow/Chart.yaml +++ b/k8s/helm/durable-workflow/Chart.yaml @@ -5,11 +5,11 @@ type: application # The chart's own semver version. Bumped on every chart release; treated as # independent of the server image version (appVersion). Breaking-change rules # for this version live in docs/helm-upgrading.md alongside the chart. -version: 0.1.88 +version: 0.1.89 # The immutable Durable Workflow Server identity this chart release packages. # The onboarding default in values.yaml and appVersion are generated from the # checked-in source release record. -appVersion: "2.3.9" +appVersion: "2.3.10" kubeVersion: ">=1.27.0-0" home: https://durable-workflow.github.io/docs/2.0/deployment sources: @@ -30,7 +30,7 @@ annotations: # exact commit that most recently changed the packaged chart. org.opencontainers.image.source: https://github.com/durable-workflow/server dev.durable-workflow.source-revision: "unreleased" - dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.3.9" + dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.3.10" artifacthub.io/license: MIT artifacthub.io/category: integration-delivery # Free-form changelog for the current chart release shown by Artifact Hub. diff --git a/k8s/helm/durable-workflow/README.md b/k8s/helm/durable-workflow/README.md index 04865b73..1e060af2 100644 --- a/k8s/helm/durable-workflow/README.md +++ b/k8s/helm/durable-workflow/README.md @@ -63,7 +63,7 @@ helm install durable-workflow ./k8s/helm/durable-workflow \ ```yaml image: - tag: "2.3.9" + tag: "2.3.10" # Pin a digest in production: # digest: "sha256:abc123..." # memoPayloadStorage: "raw-json-v1" # Required for a digest or custom image. diff --git a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml index 6eca2f3c..c1f0c7c2 100644 --- a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml @@ -1,7 +1,7 @@ # CI fixture: GitOps / externally-managed-secret path. The chart consumes # existing Secrets and renders no Secret resources of its own. image: - tag: "2.3.9" + tag: "2.3.10" externalDatabase: connection: pgsql diff --git a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml index f7e800b7..e2ddb003 100644 --- a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml +++ b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml @@ -1,6 +1,6 @@ # CI fixture: ingress + autoscaling enabled. Exercises optional templates. image: - tag: "2.3.9" + tag: "2.3.10" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml index 04696bf8..1512a2cd 100644 --- a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml @@ -2,7 +2,7 @@ # chart's render path is exercised end-to-end. Real deployments should use # existingSecret instead. image: - tag: "2.3.9" + tag: "2.3.10" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/templates/_helpers.tpl b/k8s/helm/durable-workflow/templates/_helpers.tpl index c38d7986..a3366cf7 100644 --- a/k8s/helm/durable-workflow/templates/_helpers.tpl +++ b/k8s/helm/durable-workflow/templates/_helpers.tpl @@ -88,7 +88,7 @@ resolved by an explicit capability declaration or an existing workload marker. {{- define "durable-workflow.memoPayloadStorageForImage" -}} {{- $image := toString . -}} {{- $normalized := regexReplaceAll "^index\\.docker\\.io/" $image "docker.io/" -}} -{{- if eq $normalized "docker.io/durableworkflow/server:2.3.9" -}} +{{- if eq $normalized "docker.io/durableworkflow/server:2.3.10" -}} dual-v1 {{- else if regexMatch "^docker\\.io/durableworkflow/server:2\\.0\\.0-rc\\.[0-9]+$" $normalized -}} {{- $releaseCandidate := atoi (regexFind "[0-9]+$" $normalized) -}} diff --git a/k8s/helm/durable-workflow/values.yaml b/k8s/helm/durable-workflow/values.yaml index f66f21b8..6f915bda 100644 --- a/k8s/helm/durable-workflow/values.yaml +++ b/k8s/helm/durable-workflow/values.yaml @@ -21,7 +21,7 @@ image: registry: docker.io repository: durableworkflow/server # Generated by scripts/ci/sync-source-release.mjs. Do not edit this default. - tag: "2.3.9" + tag: "2.3.10" # Optional digest pin. When set, takes precedence over tag for change control. # Example: "sha256:abc123..." digest: "" diff --git a/k8s/helm/examples/values-dev.yaml b/k8s/helm/examples/values-dev.yaml index 8cf835c8..997898c7 100644 --- a/k8s/helm/examples/values-dev.yaml +++ b/k8s/helm/examples/values-dev.yaml @@ -3,7 +3,7 @@ # shape in production. image: - tag: "2.3.9" + tag: "2.3.10" externalDatabase: connection: mysql diff --git a/k8s/helm/examples/values-external-secrets-operator.yaml b/k8s/helm/examples/values-external-secrets-operator.yaml index 85b92687..c9782baf 100644 --- a/k8s/helm/examples/values-external-secrets-operator.yaml +++ b/k8s/helm/examples/values-external-secrets-operator.yaml @@ -5,7 +5,7 @@ # concern. image: - tag: "2.3.9" + tag: "2.3.10" externalDatabase: connection: pgsql diff --git a/k8s/helm/examples/values-production-existing-secrets.yaml b/k8s/helm/examples/values-production-existing-secrets.yaml index 236d6ed8..085786ac 100644 --- a/k8s/helm/examples/values-production-existing-secrets.yaml +++ b/k8s/helm/examples/values-production-existing-secrets.yaml @@ -10,7 +10,7 @@ image: repository: durable-workflow/server # Pin a digest in production for change-control auditability. digest: "" # e.g. "sha256:abc123..." - tag: "2.3.9" + tag: "2.3.10" externalDatabase: connection: pgsql diff --git a/k8s/migration-job.yaml b/k8s/migration-job.yaml index ed4c5571..bd588aee 100644 --- a/k8s/migration-job.yaml +++ b/k8s/migration-job.yaml @@ -13,7 +13,7 @@ spec: restartPolicy: OnFailure containers: - name: migrate - image: durableworkflow/server:2.3.9 + image: durableworkflow/server:2.3.10 command: ["server-entrypoint"] args: ["server-bootstrap"] envFrom: diff --git a/k8s/scheduler-cronjob.yaml b/k8s/scheduler-cronjob.yaml index 2fcb9f5c..1128e353 100644 --- a/k8s/scheduler-cronjob.yaml +++ b/k8s/scheduler-cronjob.yaml @@ -24,7 +24,7 @@ spec: restartPolicy: Never containers: - name: scheduler - image: durableworkflow/server:2.3.9 + image: durableworkflow/server:2.3.10 command: ["server-entrypoint"] args: ["sh", "-c", "php artisan schedule:evaluate --limit=100 --json; php artisan activity:timeout-enforce --limit=100; if php artisan list --raw | grep -q '^external-payloads:cleanup '; then php artisan external-payloads:cleanup --limit=100 --json; fi; php artisan history:prune --limit=100"] envFrom: diff --git a/k8s/secret.yaml b/k8s/secret.yaml index 9ba627e9..00b3a67b 100644 --- a/k8s/secret.yaml +++ b/k8s/secret.yaml @@ -12,7 +12,7 @@ metadata: app.kubernetes.io/name: durable-workflow data: APP_NAME: "Durable Workflow Server" - APP_VERSION: "2.3.9" + APP_VERSION: "2.3.10" APP_ENV: production APP_DEBUG: "false" DB_CONNECTION: mysql diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml index 07f93111..0631a675 100644 --- a/k8s/server-deployment.yaml +++ b/k8s/server-deployment.yaml @@ -23,7 +23,7 @@ spec: spec: containers: - name: server - image: durableworkflow/server:2.3.9 + image: durableworkflow/server:2.3.10 ports: - containerPort: 8080 name: http diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml index 07eadfa4..ea215466 100644 --- a/k8s/worker-deployment.yaml +++ b/k8s/worker-deployment.yaml @@ -19,7 +19,7 @@ spec: spec: containers: - name: worker - image: durableworkflow/server:2.3.9 + image: durableworkflow/server:2.3.10 command: ["server-entrypoint"] args: ["php", "artisan", "queue:work", "--sleep=1", "--tries=3", "--max-time=3600"] envFrom: diff --git a/resources/release/source-release.json b/resources/release/source-release.json index 241e548c..cdd06534 100644 --- a/resources/release/source-release.json +++ b/resources/release/source-release.json @@ -1,9 +1,9 @@ { "schema": "durable-workflow.server.source-release/v1", "server": { - "version": "2.3.9" + "version": "2.3.10" }, "helm_chart": { - "version": "0.1.88" + "version": "0.1.89" } } diff --git a/scripts/k8s-kind-smoke.sh b/scripts/k8s-kind-smoke.sh index 189ab212..1f4e50b9 100755 --- a/scripts/k8s-kind-smoke.sh +++ b/scripts/k8s-kind-smoke.sh @@ -7,7 +7,7 @@ cluster="${K8S_SMOKE_CLUSTER:-durable-workflow-server-smoke}" image="${K8S_SMOKE_IMAGE:-durableworkflow/server:k8s-smoke}" # Generated by scripts/ci/sync-source-release.mjs so the smoke replaces the # same default shipped by the public manifests. -manifest_image="durableworkflow/server:2.3.9" +manifest_image="durableworkflow/server:2.3.10" kind_node_image="${K8S_SMOKE_KIND_NODE_IMAGE:-kindest/node:v1.29.4}" artifact_dir="${K8S_SMOKE_ARTIFACT_DIR:-/tmp/durable-workflow-k8s-kind-smoke-artifacts}" rendered_dir="${artifact_dir}/rendered-manifests" diff --git a/tests/Feature/RuntimeExternalPayloadTransportTest.php b/tests/Feature/RuntimeExternalPayloadTransportTest.php index cd54edd7..d2bdb8a0 100644 --- a/tests/Feature/RuntimeExternalPayloadTransportTest.php +++ b/tests/Feature/RuntimeExternalPayloadTransportTest.php @@ -9,6 +9,7 @@ use App\Support\RuntimeExternalPayloadQuota; use App\Support\RuntimeExternalPayloadReference; use App\Support\RuntimeExternalPayloadRegistry; +use App\Support\RuntimeLocalExternalPayloadStorage; use App\Support\WorkerProtocol; use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -139,6 +140,27 @@ public function test_retry_repairs_partial_backing_write_and_keeps_reference_ide $this->assertDatabaseCount('runtime_external_payloads', 1); } + public function test_retained_string_retry_repairs_partial_bytes_before_marking_ready(): void + { + $payload = Serializer::serializeWithCodec('avro', ['retained result']); + $hash = hash('sha256', $payload); + $driver = new RuntimeLocalExternalPayloadStorage($this->storageDirectory.'/default'); + $registry = app(RuntimeExternalPayloadRegistry::class); + $uri = $registry->storeRetained('default', $driver, $payload, 'avro', $hash); + $row = RuntimeExternalPayload::query()->sole(); + $id = $row->id; + $row->forceFill(['upload_status' => RuntimeExternalPayload::UPLOAD_WRITING])->save(); + file_put_contents(rawurldecode(parse_url($uri, PHP_URL_PATH)), 'partial'); + + $this->assertSame($uri, $registry->storeRetained('default', $driver, $payload, 'avro', $hash)); + $this->assertSame(RuntimeExternalPayload::UPLOAD_READY, $row->fresh()->upload_status); + $this->assertNotNull($row->fresh()->retained_at); + $this->assertSame($id, RuntimeExternalPayload::query()->sole()->id); + $reference = $registry->referenceForUri('default', $uri); + $this->fetch($reference)->assertOk()->assertStreamedContent($payload); + $this->assertDatabaseCount('runtime_external_payloads', 1); + } + public function test_transport_audit_events_exclude_provider_and_reusable_reference_details(): void { Log::spy(); diff --git a/tests/Unit/RuntimeLocalExternalPayloadStorageTest.php b/tests/Unit/RuntimeLocalExternalPayloadStorageTest.php new file mode 100644 index 00000000..123269f2 --- /dev/null +++ b/tests/Unit/RuntimeLocalExternalPayloadStorageTest.php @@ -0,0 +1,110 @@ +directory = storage_path('framework/testing/local-payload-commit'); + File::deleteDirectory($this->directory); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->directory); + parent::tearDown(); + } + + #[DataProvider('retryCases')] + public function test_string_retry_commits_exact_bytes(string $payload, string $previous): void + { + $driver = new RuntimeLocalExternalPayloadStorage($this->directory); + $hash = hash('sha256', $payload); + $uri = $driver->uriFor($hash, 'avro'); + $path = rawurldecode(parse_url($uri, PHP_URL_PATH)); + File::ensureDirectoryExists(dirname($path)); + file_put_contents($path, $previous); + $inode = fileinode($path); + + $this->assertSame($uri, $driver->put($payload, $hash, 'avro')); + $this->assertSame($payload, file_get_contents($path)); + $this->assertSame($hash, hash_file('sha256', $path)); + $this->assertSame($inode, fileinode($path)); + $this->assertSame($uri, $driver->put($payload, $hash, 'avro')); + $this->assertSame($payload, file_get_contents($path)); + } + + public static function retryCases(): array + { + return [ + 'partial' => ['complete-payload', 'partial'], + 'equal-length corruption' => ['correct', 'corrupt'], + 'binary' => ["value\xff\x00", "value\x00\x00"], + 'empty' => ['', 'stale'], + 'already committed' => ['correct', 'correct'], + ]; + } + + public function test_string_and_stream_share_identity_without_a_second_large_copy(): void + { + $driver = new RuntimeLocalExternalPayloadStorage($this->directory); + $payload = str_repeat('x', 16 * 1024 * 1024); + $hash = hash('sha256', $payload); + memory_reset_peak_usage(); + $before = memory_get_usage(true); + $uri = $driver->put($payload, $hash, 'avro'); + $this->assertLessThan(4 * 1024 * 1024, memory_get_peak_usage(true) - $before); + $path = rawurldecode(parse_url($uri, PHP_URL_PATH)); + $source = fopen($path, 'rb'); + + try { + $this->assertSame($uri, $driver->putStream($source, $hash, 'avro')); + $this->assertSame($hash, hash_file('sha256', $path)); + } finally { + fclose($source); + } + } + + public function test_short_stream_write_is_repairable_by_a_fresh_string_writer(): void + { + $driver = new RuntimeLocalExternalPayloadStorage($this->directory); + $payload = "retained\x00payload\xff"; + $hash = hash('sha256', $payload); + $uri = $driver->uriFor($hash, 'avro'); + $stream = tmpfile(); + fwrite($stream, $payload); + fseek($stream, 5); + + try { + $driver->putStream($stream, $hash, 'avro'); + $this->fail('An incomplete stream must not be acknowledged.'); + } catch (RuntimeException $exception) { + $this->assertSame('Unable to commit external payload bytes.', $exception->getMessage()); + } finally { + fclose($stream); + } + + $path = rawurldecode(parse_url($uri, PHP_URL_PATH)); + $this->assertSame(substr($payload, 5), file_get_contents($path)); + $process = new Process([PHP_BINARY, '-r', <<<'PHP' + require 'vendor/autoload.php'; + $driver = new App\Support\RuntimeLocalExternalPayloadStorage($argv[1]); + $payload = base64_decode($argv[2], true); + echo $driver->put($payload, hash('sha256', $payload), 'avro'); + PHP, $this->directory, base64_encode($payload)], base_path(), timeout: 10); + $process->mustRun(); + $this->assertSame($uri, $process->getOutput()); + $this->assertSame($payload, file_get_contents($path)); + } +}